From ac64b5c886b9541614294ec5d5435917b85f3337 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 04:31:05 -0700 Subject: [PATCH 01/80] feat(sdk): isolate hosted Babysitter capability Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 31 +- docs/CLOUD.md | 14 +- evidence/babysitter-gate8-isolation/README.md | 34 ++ .../verification.txt | 58 ++ extensions/babysitter/babysitter.flow.ts | 11 +- packages/sdk/src/flow-extension-loader.ts | 7 +- .../sdk/src/hosted-extension-isolation.ts | 564 ++++++++++++++++++ .../src/hosted-extension-sandbox-source.ts | 94 +++ packages/sdk/src/index.ts | 10 + .../tests/babysitter-native-extension.test.ts | 28 + .../tests/hosted-extension-isolation.test.ts | 306 ++++++++++ packages/sdk/tsconfig.tests.json | 1 + packages/surface/src/cloud.ts | 27 + packages/surface/src/context.ts | 4 +- packages/surface/src/index.ts | 4 + 15 files changed, 1181 insertions(+), 12 deletions(-) create mode 100644 evidence/babysitter-gate8-isolation/README.md create mode 100644 evidence/babysitter-gate8-isolation/verification.txt create mode 100644 packages/sdk/src/hosted-extension-isolation.ts create mode 100644 packages/sdk/src/hosted-extension-sandbox-source.ts create mode 100644 packages/sdk/tests/hosted-extension-isolation.test.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index d074e7691..004c94795 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -19,12 +19,39 @@ exact live `babysit` label and the bound session/head. Permission declarations are not enforcement. Export success is byte verification, not execution approval. The native package source is `extensions/babysitter` (see its README for the -turn contract). It is unreleased and cannot execute: #549 still refuses it, -the SDK context has no `capabilities.cloud.babysitterTurn`, and its +turn contract). It is unreleased and cannot execute through the generic +executor: #549 still refuses it. The SDK now has a separate Linux-only +capability sandbox that injects exactly +`capabilities.cloud.babysitterTurn.queue` without exposing the base context, +workspace, environment credentials, network, helpers, MCP, or harnesses. It is +not wired to hosted dispatch and must not be treated as enablement. The package's `compat` requires a surface release after 2.0.25 that routes `labeled`, `unlabeled`, and `ready_for_review`. Export it only from a reviewed, merged commit. +The sandbox contract is deliberately narrower than #442. It re-verifies the +content-addressed artifact and manifest, accepts only the native Babysitter +permission profile, imports the entry only inside Linux bubblewrap plus Node's +permission model, mounts a minimal trusted Surface facade (`flow`, `github`, +and `getFlowDefinition`) instead of the general helper runtime, checks +normalized input against non-serializable verified dispatch authority, and +permits one queue call. The parent capability adapter +receives that original authority plus immutable extension provenance; the +capability request never carries workspace, activation, listener, session, +lineage, label, head, prompt, merge, route, or config authority. Cloud PR #3942 owns the +lineage/authority core and must inject workspace, activation, and listener from +persisted dispatch context, re-read live PR/label/head state, and return only +`{ receiptId, status: 'queued' | 'duplicate' }`. Refusal or in-doubt transport +rejects once with no fallback. + +Before replacing #549's refusal, the hosted caller must use +`loadAuthoredFlow(..., { extensions: 'none' })`, obtain raw verified artifacts +with `loadHostedExtensionArtifacts`, and call `runHostedCapabilityExtension`; +using the ordinary compose loader would import extension top-level JavaScript +in the host before the sandbox exists. Independent review must prove this path +at the exact release head. Broader per-agent-step file/network/access-preset +enforcement remains open in #442 and is not claimed by this slice. + ## Export reviewed bytes After the native package is reviewed and committed, build the SDK and run: diff --git a/docs/CLOUD.md b/docs/CLOUD.md index eec520930..ceb08ebfc 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -389,8 +389,18 @@ handler keep the base flow's default body; overlapping or malformed matches fail closed. This release also refuses a matching handler with `plugin_unsupported` before either body starts because schema-2 entries are ordinary JavaScript and their manifest permissions are not yet isolated by the -runtime (gate 8 / #442). A handler's trigger is therefore a requirement, but -the handler cannot execute until that boundary exists. The deploy body carries +runtime (gate 8 / #442). The SDK now contains a Linux-only, capability-only +isolation primitive for the native Babysitter profile. Hosted callers must load +the base with `extensions: 'none'`, resolve extension bytes with +`loadHostedExtensionArtifacts` (which does not import them), then use +`runHostedCapabilityExtension`; the artifact is first imported inside a +bubblewrap mount/PID/network/user namespace with an empty credential +environment and a context exposing only +`capabilities.cloud.babysitterTurn.queue` plus `done`. This is a prerequisite, +not enablement: the generic executor refusal remains until the Cloud adapter, +artifact provenance, and independent security review are complete. A handler's +trigger is therefore a requirement, but the handler cannot execute through the +generic executor yet. The deploy body carries the same list as `requirements` for Cloud to cross-check, and a declared harness Cloud cannot run yet (`gemini`) refuses the deploy unless `--agents` overrides it. diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md new file mode 100644 index 000000000..d25ee577a --- /dev/null +++ b/evidence/babysitter-gate8-isolation/README.md @@ -0,0 +1,34 @@ +# Native Babysitter gate-8 isolation evidence + +This slice adds a Linux-only, capability-only execution primitive for the +immutable native Babysitter extension. It does not wire hosted dispatch, change +the generic executor refusal introduced by #549, publish a package, or enable a +deployment. + +The enforced boundary is: + +- load the base flow with extensions disabled and resolve extension artifacts + without importing extension JavaScript; +- reverify the content-addressed artifact, lock metadata, manifest hash, exact + immutable native source, runtime compatibility, and narrow permission profile; +- validate a symbol-branded verified dispatch and a closed normalized delivery + descriptor before import; +- import and execute the matching handler only inside a bubblewrap namespace + plus Node's permission model, with no network, writable filesystem, inherited + environment, child process, workspace mount, MCP, helpers, harnesses, or base + flow context; a generated facade mounts only `flow`, `github`, + `getFlowDefinition`, and their six reviewed Surface runtime files; +- expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and + `done`, validate the exact request and `{ receiptId, status }` response in the + parent, and pass the original non-serializable authority to the host adapter; +- fail closed on unknown frames, repeated/omitted calls, premature completion, + timeouts, adapter rejection, incompatible bytes, and broader permissions. + +Cloud PR #3942 remains responsible for persisted dispatch context, live PR and +label/head revalidation, authorized existing-session resolution, lineage-based +deduplication, and Relay native-turn delivery. The broader per-step permission +enforcement requested by #442 is not implemented here and remains a separate +gate. The committed extension declares SDK/Surface `^2.0.26`; current 2.0.25 +correctly refuses it before import. + +See `verification.txt` for the final local verification record. diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt new file mode 100644 index 000000000..15d7af6e6 --- /dev/null +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -0,0 +1,58 @@ +Native Babysitter gate-8 isolation — final local verification +Host: Linux sf-frame +Date: 2026-09-22 + +$ (cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts) +@relayflows/sdk typecheck: PASS +@relayflows/sdk build: PASS +@relayflows/sdk test typecheck: PASS +Test Files 4 passed (4) +Tests 134 passed (134) + hosted-extension-isolation.test.ts 9 passed + babysitter-native-extension.test.ts 32 passed + authored-flow.test.ts 34 passed + preflight.test.ts 59 passed +Exit: 0 +The focused command was rerun after the package gate installed the packed +Surface tarball into SDK node_modules; the minimal Surface facade passed without +relying on the source checkout's peer dependencies. + +$ (cd packages/sdk && /home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin/node ./node_modules/vitest/vitest.mjs run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts) +Test Files 2 passed (2) +Tests 41 passed (41) +Exit: 0 + +$ npm run typecheck --prefix packages/surface +Exit: 0 + +$ npm run typecheck:regressions --prefix packages/surface +HELPERS_GENERATED_OK airtable.ts, asana.ts, azure-blob.ts, box.ts, calendly.ts, clickup.ts, clients.ts, cloudflare.ts, confluence.ts, daytona.ts, docker-hub.ts, dropbox.ts, fathom.ts, gcp.ts, gcs.ts, github.ts, gitlab.ts, gmail.ts, google-calendar.ts, google-drive.ts, granola.ts, hubspot.ts, index.ts, intercom.ts, jira.ts, linear.ts, mailgun.ts, mixpanel.ts, neon.ts, notion.ts, onedrive.ts, pipedrive.ts, postgres.ts, posthog.ts, providers.ts, ramp.ts, recall.ts, reddit.ts, redis.ts, s3.ts, salesforce.ts, segment.ts, sendgrid.ts, sharepoint.ts, shopify.ts, shortcut.ts, slack.ts, stripe.ts, teams.ts, telegram.ts, webhook-server.ts, x.ts, zendesk.ts +Exit: 0 + +$ npm test --prefix packages/surface +Test Files 9 passed (9) +Tests 51 passed (51) +Exit: 0 + +$ bash scripts/surface-package-gate.sh +PACKED_RUNTIME_OK name=packed-runtime-consumer completionReason=success +PACKED_RUNTIME_REFUSAL_OK invalidHeaders=9 forgedHandle=refused +PACKED_TYPESCRIPT_OK +Test Files 1 passed (1) +Tests 34 passed (34) +Exit: 0 + +$ (cd packages/sdk && npm test) +Test Files 8 failed | 156 passed | 3 skipped (167) +Tests 14 failed | 2603 passed | 25 skipped (2642) +Errors 1 error +Exit: 1 + +The new hosted-extension isolation suite passed inside the full run. The full +run's failures were outside the changed boundary and host-dependent: Bun 1.4.2 +versus the suite's pinned 1.4.0; missing kernel/target debug or release +relayflowd paths while the toolchain wrapper built elsewhere; unavailable real +Claude analyzer auth; one missing live connection file; one existing standalone +bundle second-build refusal; and one helper replay filesystem write error. +These failures are reported rather than suppressed; the focused final suites +above are the acceptance evidence for this slice. diff --git a/extensions/babysitter/babysitter.flow.ts b/extensions/babysitter/babysitter.flow.ts index 35f21d45a..8b078ad69 100644 --- a/extensions/babysitter/babysitter.flow.ts +++ b/extensions/babysitter/babysitter.flow.ts @@ -11,14 +11,13 @@ interface TurnQueue { } /** - * The declared write, as Cloud's capability adapter spells it. The SDK `Ctx` - * has no `capabilities` field yet; a runtime without it must fail the run, - * never skip the turn silently. The adapter, not this entry, holds workspace, - * activation, and the verified delivery authority. + * The declared write, as Cloud's capability adapter spells it. `Ctx` types the + * optional port, but ordinary runtimes do not inject it; a runtime without it + * must fail the run, never skip the turn silently. The adapter, not this entry, + * holds workspace, activation, and the verified delivery authority. */ function turnQueue(f: Ctx): TurnQueue { - const capabilities = (f as unknown as { readonly capabilities?: { readonly cloud?: { readonly babysitterTurn?: unknown } } }).capabilities; - const port = capabilities?.cloud?.babysitterTurn; + const port = f.capabilities?.cloud?.babysitterTurn; if (typeof port !== 'object' || port === null || typeof (port as Partial).queue !== 'function') { throw new Error('babysitter: this runtime does not provide the cloud:babysitter-turn write.'); } diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index be1d90110..32467af24 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -115,7 +115,7 @@ export type HostedExtensionDispatch = { readonly deliveryId: string; }; -type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string }; +export type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string }; const DISPATCH_PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/; const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/; const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/; @@ -141,6 +141,11 @@ function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { : { provider, event: parts[0]!, action: parts[1]! }; } +/** Validate and project branded host authority without making it serializable. */ +export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventIdentity { + return Object.freeze(hostedEventIdentity(dispatch)); +} + /** * Brand metadata only after the host has authenticated the integration * delivery. The symbol is deliberately not serializable, so copying a direct diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts new file mode 100644 index 000000000..6f65ab151 --- /dev/null +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -0,0 +1,564 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { dirname, join, parse, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; +import { canonicalize } from './canonical.js'; +import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; +import { assertCompatible, runtimeVersions } from './flow-extension-compat.js'; +import { + hostedExtensionDispatchIdentity, + type HostedExtensionDispatch, +} from './flow-extension-loader.js'; +import { PluginError } from './plugin-manifest.js'; +import { findPluginProject } from './plugin-loader.js'; +import { reconcileDeclaredExtensions } from './plugin-lock.js'; +import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; +import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const MAX_FRAME_BYTES = 256 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const DEFAULT_TIMEOUT_MS = 10_000; +const BABYSITTER_REF = /^github:AgentWorkforce\/flows@[a-f0-9]{40}#extensions\/babysitter$/; +const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; +const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,200}$/; +const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const REPOSITORY = /^[A-Za-z0-9._-]{1,100}$/; +const HEAD_SHA = /^[a-f0-9]{40}$/; +const NATIVE_TRIGGERS = [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, +]; + +export interface HostedExtensionArtifact { + readonly ref: string; + readonly name: string; + readonly version: string; + readonly directory: string; + readonly digest: string; + readonly manifestSha256: string; +} + +export interface HostedCapabilityAuthority { + readonly dispatch: HostedExtensionDispatch; + readonly extension: Readonly<{ + name: string; + version: string; + ref: string; + digest: string; + }>; +} + +export interface HostedBabysitterCapability { + queue(request: unknown, authority: HostedCapabilityAuthority): Promise; +} + +export interface RunHostedExtensionOptions { + readonly artifact: HostedExtensionArtifact; + readonly dispatch: HostedExtensionDispatch; + /** Host-normalized delivery descriptor. Its event identity must equal dispatch. */ + readonly input: unknown; + readonly babysitterTurn: HostedBabysitterCapability; + readonly timeoutMs?: number; + /** Test/packaging override. Production resolves /usr/bin/bwrap. */ + readonly bubblewrapPath?: string; + /** Test/packaging override. Defaults to the current, fingerprinted Node executable. */ + readonly nodePath?: string; +} + +export interface HostedExtensionResult { + readonly completionReason: 'success'; + readonly capabilityCalls: 1; +} + +/** + * Resolve installed extension artifacts without importing their JavaScript. + * Hosted callers pair this with `loadAuthoredFlow(..., { extensions: 'none' })`; + * using the ordinary compose loader would execute extension top-level code in + * the host before the sandbox exists. + */ +export async function loadHostedExtensionArtifacts( + flowPath: string, +): Promise { + const root = findPluginProject(dirname(resolve(flowPath))); + if (root === undefined) return Object.freeze([]); + const artifacts: HostedExtensionArtifact[] = []; + for (const { ref, entry } of reconcileDeclaredExtensions(root)) { + const directory = pluginStoreDirectory(root, entry.name, entry.digest); + await verifyStoredPlugin(directory, entry.digest); + artifacts.push(Object.freeze({ + ref, + name: entry.name, + version: entry.version, + directory, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + })); + } + return Object.freeze(artifacts); +} + +/** + * Execute a capability-only hosted extension in a Linux mount/PID/network/user + * namespace. The extension is first imported inside that namespace. It sees + * no host workspace, environment credentials, network, child process, MCP, + * helpers, harnesses, or base-flow context. The parent exposes exactly one + * authenticated capability adapter and passes the original branded dispatch + * authority to that adapter out of band. + * + * This is deliberately not wired into executeAuthoredFlow yet. #549's refusal + * remains the rollout gate until the Cloud adapter and independent review land. + */ +export async function runHostedCapabilityExtension( + options: RunHostedExtensionOptions, +): Promise { + if (process.platform !== 'linux') return unsupported('hosted extension isolation requires Linux'); + const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); + const node = executable(options.nodePath ?? process.execPath, 'Node'); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); + } + + const manifest = await verifiedManifest(options.artifact); + assertCapabilityOnlyManifest(manifest); + const versions = runtimeVersions(); + assertCompatible(manifest, versions); + const identity = hostedExtensionDispatchIdentity(options.dispatch); + assertManifestRoutes(manifest, identity); + const normalizedInput = babysitterInput(options.input, options.dispatch); + const entryPath = resolve(options.artifact.directory, manifest.entry); + if (!entryPath.startsWith(`${resolve(options.artifact.directory)}/`)) { + throw new PluginError('plugin_path_invalid', `${manifest.entry}: hosted extension entry escapes its artifact.`); + } + const surfaceRoot = resolveSurfaceRoot(versions.surface); + + const runtimeDirectory = await mkdtemp(join(tmpdir(), 'flows-hosted-extension-')); + const runner = join(runtimeDirectory, 'runner.mjs'); + const surfaceFacade = join(runtimeDirectory, 'surface'); + try { + await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); + await writeSurfaceFacade(surfaceFacade); + const args = sandboxArguments({ + node, runner, extension: realpathSync(options.artifact.directory), surfaceFacade, surfaceRoot, + }); + const child = spawn(bwrap, args, { + cwd: '/', env: {}, + stdio: ['pipe', 'ignore', 'pipe', 'pipe'], + }); + const protocol = child.stdio[3] as Readable; + const stdin = child.stdin as Writable; + const stderr = child.stderr as Readable; + const authority: HostedCapabilityAuthority = Object.freeze({ + dispatch: options.dispatch, + extension: Object.freeze({ + name: manifest.name, + version: manifest.version, + ref: options.artifact.ref, + digest: options.artifact.digest, + }), + }); + return await exchange(child, protocol, stdin, stderr, timeoutMs, { + type: 'execute', + entry: `/extension/src/${manifest.entry}`, + surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', + capability: HOSTED_WRITE, + identity, + input: normalizedInput, + }, async request => babysitterReceipt(await options.babysitterTurn.queue( + babysitterRequest(request, normalizedInput, options.dispatch), + authority, + ))); + } finally { + await rm(runtimeDirectory, { recursive: true, force: true }); + } +} + +async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { + if (!BABYSITTER_REF.test(artifact.ref)) { + throw new PluginError('plugin_source_invalid', `${artifact.ref}: hosted capability isolation accepts only the immutable native Babysitter source.`); + } + if (!/^[a-f0-9]{64}$/.test(artifact.digest) || !/^[a-f0-9]{64}$/.test(artifact.manifestSha256)) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extension digests are malformed.`); + } + await verifyStoredPlugin(artifact.directory, artifact.digest); + const payload = JSON.parse((await readFile(join(artifact.directory, 'manifest.json'))).toString('utf8')) as Array<{ path?: unknown }>; + if (payload.some(file => typeof file.path === 'string' && (file.path === 'node_modules' || file.path.startsWith('node_modules/')))) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); + } + const bytes = await readFile(join(artifact.directory, 'flows-plugin.json')); + if (sha256(bytes) !== artifact.manifestSha256) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json differs from the lockfile's manifest hash.`); + } + let input: unknown; + try { input = JSON.parse(bytes.toString('utf8')); } + catch { throw new PluginError('plugin_manifest_invalid', `${artifact.ref}: flows-plugin.json is not valid JSON.`); } + const manifest = validateFlowExtensionManifest(input); + if (manifest.name !== artifact.name || manifest.version !== artifact.version) { + throw new PluginError( + 'plugin_source_drift', + `${artifact.ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${artifact.name}@${artifact.version}.`, + ); + } + return manifest; +} + +function assertCapabilityOnlyManifest(manifest: FlowExtensionManifest): void { + const expected = { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes: [HOSTED_WRITE], + base: [{ name: 'software-factory', version: '*' }], + budget: { dollars: 1, wallclock: '5m' }, + triggers: NATIVE_TRIGGERS, + }; + const actual = { + integrations: manifest.permissions.integrations, + harnesses: manifest.permissions.harnesses, + mcp: manifest.permissions.mcp, + writes: manifest.permissions.writes, + base: manifest.compat.base, + budget: manifest.permissions.budget, + triggers: manifest.triggers, + }; + if (canonicalize(actual) !== canonicalize(expected) + || manifest.name !== 'babysitter' + || manifest.entry !== 'babysitter.flow.ts' + || !manifest.extends.handlers || manifest.extends.hooks.length !== 0 + || manifest.preflight.credentials.length !== 0 || manifest.preflight.servers.length !== 0) { + throw new PluginError( + 'plugin_unsupported', + `${manifest.name}: hosted capability isolation accepts only the native Babysitter permission profile.`, + ); + } +} + +function assertManifestRoutes( + manifest: FlowExtensionManifest, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, +): void { + const matches = manifest.triggers.filter(trigger => trigger.provider === identity.provider + && trigger.event === identity.event + && (trigger.actions.length === 0 ? identity.action === undefined : identity.action !== undefined && trigger.actions.includes(identity.action))); + if (matches.length !== 1) { + throw new PluginError('plugin_event_unroutable', `${manifest.name}: hosted event is not declared exactly once.`); + } +} + +function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { + const top = exactRecord(input, 'Hosted extension input', ['event', 'pullRequest']); + const event = exactRecord(top.event, 'Hosted extension event', ['provider', 'eventType', 'deliveryId']); + const pullRequest = optionalRecord( + top.pullRequest, + 'Hosted extension pull request', + ['host', 'owner', 'repo', 'number', 'headSha'], + ['owner', 'repo', 'number'], + ); + if (event.provider !== dispatch.provider || event.eventType !== dispatch.eventType + || event.deliveryId !== dispatch.deliveryId || event.provider !== 'github' + || !DELIVERY_ID.test(String(event.deliveryId)) + || (pullRequest.host !== undefined && pullRequest.host !== 'github') + || typeof pullRequest.owner !== 'string' || !OWNER.test(pullRequest.owner) + || typeof pullRequest.repo !== 'string' || !REPOSITORY.test(pullRequest.repo) + || pullRequest.repo === '.' || pullRequest.repo === '..' + || typeof pullRequest.number !== 'number' || !Number.isSafeInteger(pullRequest.number) || pullRequest.number <= 0 + || (pullRequest.headSha !== undefined + && (typeof pullRequest.headSha !== 'string' || !HEAD_SHA.test(pullRequest.headSha)))) { + throw new PluginError('plugin_event_unroutable', 'Hosted extension input does not match the verified GitHub delivery.'); + } + return jsonSnapshot(input, 'hosted extension input'); +} + +function resolveSurfaceRoot(expectedVersion: string): string { + let resolved: string; + try { resolved = realpathSync(createRequire(import.meta.url).resolve('@relayflows/surface')); } + catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } + let directory = dirname(resolved); + const root = parse(directory).root; + while (directory !== root) { + const packageJson = join(directory, 'package.json'); + if (existsSync(packageJson)) { + try { + const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; + if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { + return realpathSync(directory); + } + } catch { /* keep walking */ } + } + directory = dirname(directory); + } + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); +} + +function babysitterRequest(value: unknown, input: unknown, dispatch: HostedExtensionDispatch): unknown { + const request = exactRecord(value, 'Babysitter capability request', ['delivery']); + const delivery = exactRecord(request.delivery, 'Babysitter delivery', ['deliveryId', 'provider', 'eventType', 'pullRequest']); + const pullRequest = exactRecord(delivery.pullRequest, 'Babysitter pull request', ['owner', 'repository', 'number']); + const normalized = exactRecord(input, 'Hosted extension input', ['event', 'pullRequest']); + const inputPullRequest = record(normalized.pullRequest, 'Hosted extension pull request'); + if (delivery.deliveryId !== dispatch.deliveryId || !DELIVERY_ID.test(String(delivery.deliveryId)) + || delivery.provider !== 'github' || delivery.eventType !== dispatch.eventType + || typeof pullRequest.owner !== 'string' || !OWNER.test(pullRequest.owner) + || typeof pullRequest.repository !== 'string' || !REPOSITORY.test(pullRequest.repository) + || pullRequest.repository === '.' || pullRequest.repository === '..' + || typeof pullRequest.number !== 'number' || !Number.isSafeInteger(pullRequest.number) || pullRequest.number <= 0 + || pullRequest.owner !== inputPullRequest.owner + || pullRequest.repository !== inputPullRequest.repo + || pullRequest.number !== inputPullRequest.number) { + throw new PluginError('plugin_event_unroutable', 'Babysitter capability request does not match verified delivery input.'); + } + return jsonSnapshot(value, 'Babysitter capability request'); +} + +function babysitterReceipt(value: unknown): unknown { + const receipt = exactRecord(value, 'Babysitter capability receipt', ['receiptId', 'status']); + if (typeof receipt.receiptId !== 'string' || !RECEIPT_ID.test(receipt.receiptId) + || (receipt.status !== 'queued' && receipt.status !== 'duplicate')) { + throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); + } + return Object.freeze({ receiptId: receipt.receiptId, status: receipt.status }); +} + +function record(value: unknown, what: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new PluginError('plugin_event_unroutable', `${what} must be an object.`); + } + return value as Record; +} + +function exactRecord(value: unknown, what: string, keys: readonly string[]): Record { + const object = record(value, what); + const actual = Object.keys(object).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new PluginError('plugin_event_unroutable', `${what} must contain exactly ${keys.join(', ')}.`); + } + return object; +} + +function optionalRecord( + value: unknown, + what: string, + allowed: readonly string[], + required: readonly string[], +): Record { + const object = record(value, what); + const extra = Object.keys(object).filter(key => !allowed.includes(key)); + const missing = required.filter(key => !Object.hasOwn(object, key)); + if (extra.length > 0 || missing.length > 0) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } + return object; +} + +async function writeSurfaceFacade(directory: string): Promise { + await mkdir(directory); + await Promise.all([ + writeFile(join(directory, 'package.json'), JSON.stringify({ + name: '@relayflows/surface', + type: 'module', + exports: { '.': './index.js', './runtime': './runtime.js' }, + }), { mode: 0o400, flag: 'wx' }), + writeFile( + join(directory, 'index.js'), + "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", + { mode: 0o400, flag: 'wx' }, + ), + writeFile( + join(directory, 'runtime.js'), + "export { getFlowDefinition } from './dist/flow.js';\n", + { mode: 0o400, flag: 'wx' }, + ), + ]); +} + +function sandboxArguments(input: { + node: string; + runner: string; + extension: string; + surfaceFacade: string; + surfaceRoot: string; +}): string[] { + const args = [ + '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', + '--dir', '/usr', + ]; + // Bind only dynamic-library roots, not all of /usr (which commonly includes + // compilers, shells, package managers, and occasionally source worktrees). + for (const path of ['/usr/lib', '/usr/lib64', '/lib', '/lib64']) { + if (!existsSync(path)) continue; + args.push('--ro-bind', realpathSync(path), path); + } + args.push( + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', + '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', + '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', + '--dir', '/extension/node_modules/@relayflows/surface', + '--ro-bind', join(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', + '--ro-bind', join(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', + '--ro-bind', join(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', + '--dir', '/extension/node_modules/@relayflows/surface/dist', + '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', + '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', + ...surfaceRuntimeMounts(input.surfaceRoot), + '--ro-bind', input.extension, '/extension/src', + '--chdir', '/extension/src', + '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', + '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', + '--allow-fs-read=/runtime', '--allow-fs-read=/extension', '/runtime/runner.mjs', + ); + return args; +} + +function surfaceRuntimeMounts(surfaceRoot: string): string[] { + const files = [ + 'flow.js', + 'helpers/providers.js', + 'provider-trigger.js', + 'schedule.js', + 'triggers.js', + 'triggers/github.js', + ]; + return files.flatMap(file => { + const source = realpathSync(join(surfaceRoot, 'dist', file)); + return ['--ro-bind', source, `/extension/node_modules/@relayflows/surface/dist/${file}`]; + }); +} + +function executable(path: string, name: string): string { + let real: string; + try { real = realpathSync(path); } + catch { return unsupported(`${name} is unavailable`); } + if (!lstatSync(real).isFile()) return unsupported(`${name} is not a regular file`); + return real; +} + +async function exchange( + child: ReturnType, + protocol: Readable, + stdin: Writable, + stderr: Readable, + timeoutMs: number, + request: unknown, + invoke: (request: unknown) => Promise, +): Promise { + let buffer = ''; + let stderrText = ''; + let calls = 0; + stderr.setEncoding('utf8'); + stderr.on('data', chunk => { stderrText = (stderrText + String(chunk)).slice(-MAX_STDERR_BYTES); }); + protocol.setEncoding('utf8'); + const timeout = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + timeout.unref(); + return await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let capabilityState: 'none' | 'pending' | 'completed' | 'failed' = 'none'; + const finish = (error?: Error, result?: HostedExtensionResult) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + stdin.end(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + if (error !== undefined) rejectPromise(error); + else resolvePromise(result!); + }; + const refuse = (message: string) => { + child.kill('SIGKILL'); + finish(new PluginError('plugin_unsupported', message)); + }; + stdin.on('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension capability channel closed.'))); + protocol.on('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension protocol channel failed.'))); + protocol.on('data', chunk => { + buffer += String(chunk); + if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); + for (;;) { + const end = buffer.indexOf('\n'); + if (end < 0) break; + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let message: Record; + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return refuse('Hosted extension emitted a non-object protocol frame.'); + } + message = parsed as Record; + } + catch { return refuse('Hosted extension emitted malformed protocol data.'); } + if (message.type === 'capability') { + if (!hasExactKeys(message, ['type', 'id', 'name', 'request'])) { + return refuse('Hosted extension emitted a malformed capability frame.'); + } + calls += 1; + if (calls !== 1 || capabilityState !== 'none' || message.name !== HOSTED_WRITE || message.id !== 1) { + return refuse('Hosted extension requested an undeclared or repeated capability.'); + } + capabilityState = 'pending'; + void invoke(message.request).then( + value => { + if (settled) return; + try { + const snapshot = jsonSnapshot(value, 'hosted capability result'); + capabilityState = 'completed'; + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); + } catch (error) { + capabilityState = 'failed'; + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: error instanceof Error ? error.message : String(error) })}\n`); + } + }, + error => { + if (settled) return; + capabilityState = 'failed'; + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: error instanceof Error ? error.message : String(error) })}\n`); + }, + ); + } else if (message.type === 'result') { + if (!hasExactKeys(message, ['type', 'completionReason', 'capabilityCalls']) + || calls !== 1 || capabilityState !== 'completed' + || message.completionReason !== 'success' || message.capabilityCalls !== 1) { + return refuse('Hosted extension reported a completion without exactly one capability call.'); + } + finish(undefined, Object.freeze({ completionReason: 'success', capabilityCalls: 1 })); + } else if (message.type === 'error') { + if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { + return refuse('Hosted extension emitted a malformed error frame.'); + } + finish(new PluginError('plugin_unsupported', `Hosted extension failed: ${String(message.message).slice(0, 8192)}`)); + } else return refuse('Hosted extension emitted an unknown protocol message.'); + } + }); + child.once('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension sandbox could not start.'))); + child.once('close', code => { + if (!settled) finish(new PluginError( + 'plugin_unsupported', + `Hosted extension sandbox exited without a valid completion (exit ${code ?? 'signal'})${stderrText === '' ? '' : `: ${stderrText}`}`, + )); + }); + stdin.write(`${JSON.stringify(request)}\n`); + }); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function jsonSnapshot(value: unknown, what: string): unknown { + const encoded = JSON.stringify(value); + if (encoded === undefined || Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { + throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); + } + return JSON.parse(encoded) as unknown; +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function unsupported(message: string): never { + throw new PluginError('plugin_unsupported', message); +} diff --git a/packages/sdk/src/hosted-extension-sandbox-source.ts b/packages/sdk/src/hosted-extension-sandbox-source.ts new file mode 100644 index 000000000..f40e816d1 --- /dev/null +++ b/packages/sdk/src/hosted-extension-sandbox-source.ts @@ -0,0 +1,94 @@ +/** + * Source for the process that imports one hosted extension inside the Linux + * namespace assembled by hosted-extension-isolation.ts. + * + * Keep this dependency-free. The parent writes it to a private file and + * mounts that file read-only; the extension is not imported by the host. + */ +export const HOSTED_EXTENSION_SANDBOX_SOURCE = String.raw` +import { createReadStream, writeSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { pathToFileURL } from 'node:url'; + +const lines = createInterface({ input: createReadStream(null, { fd: 0 }), crlfDelay: Infinity }); +const iterator = lines[Symbol.asyncIterator](); +const send = value => writeSync(3, JSON.stringify(value) + '\n'); +const receive = async () => { + const next = await iterator.next(); + if (next.done) throw new Error('hosted capability channel closed'); + return JSON.parse(next.value); +}; +const subscriptionOf = handler => { + const trigger = handler?.trigger; + const filter = trigger?.filter; + if (trigger?.kind !== 'webhook' || filter === undefined + || typeof filter.provider !== 'string' || filter.provider !== trigger.name + || typeof filter.type !== 'string') return undefined; + const action = typeof filter.payload === 'object' && filter.payload !== null && !Array.isArray(filter.payload) + ? filter.payload.action : undefined; + if (action !== undefined && typeof action !== 'string') return undefined; + return action === undefined + ? { provider: filter.provider, event: filter.type } + : { provider: filter.provider, event: filter.type, action }; +}; +const matches = (subscription, identity) => subscription !== undefined + && subscription.provider === identity.provider + && subscription.event === identity.event + && (subscription.action === undefined || subscription.action === identity.action); + +try { + const request = await receive(); + if (request?.type !== 'execute' || typeof request.entry !== 'string' + || typeof request.surfaceRuntime !== 'string' || typeof request.capability !== 'string') { + throw new Error('malformed hosted extension execution request'); + } + const runtime = await import(pathToFileURL(request.surfaceRuntime).href); + const authored = await import(pathToFileURL(request.entry).href); + const definition = runtime.getFlowDefinition(authored.default); + const selected = definition.handlers.filter(handler => matches(subscriptionOf(handler), request.identity)); + if (selected.length !== 1) throw new Error('hosted event must match exactly one isolated extension handler'); + + let nextCall = 1; + let capabilityCalls = 0; + let completion; + const invoke = async value => { + capabilityCalls += 1; + if (capabilityCalls !== 1) throw new Error('hosted extension may invoke its capability once'); + const id = nextCall++; + send({ type: 'capability', id, name: request.capability, request: value }); + const response = await receive(); + if (response?.type !== 'capability-result' || response.id !== id || typeof response.ok !== 'boolean') { + throw new Error('malformed hosted capability response'); + } + if (!response.ok) throw new Error(typeof response.error === 'string' ? response.error : 'hosted capability refused'); + return response.value; + }; + const capabilities = Object.freeze({ + cloud: Object.freeze({ babysitterTurn: Object.freeze({ queue: invoke }) }), + }); + const available = Object.freeze({ + capabilities, + done(reason) { + if (completion !== undefined) throw new Error('hosted extension completed more than once'); + completion = reason; + }, + }); + const context = new Proxy(available, { + get(target, property, receiver) { + if (property !== 'capabilities' && property !== 'done') { + throw new Error('hosted extension context denies ' + String(property)); + } + return Reflect.get(target, property, receiver); + }, + has(_target, property) { return property === 'capabilities' || property === 'done'; }, + ownKeys() { return ['capabilities', 'done']; }, + }); + await selected[0].body(context, request.input); + if (capabilityCalls !== 1) throw new Error('hosted extension did not invoke its capability exactly once'); + if (completion !== 'success') throw new Error('hosted extension did not complete with success'); + send({ type: 'result', completionReason: completion, capabilityCalls }); +} catch (error) { + send({ type: 'error', message: error instanceof Error ? (error.stack ?? error.message) : String(error) }); + process.exitCode = 1; +} +`; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f65fa12df..2aedb5c7b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -58,6 +58,16 @@ export type { HeaderBudget } from './budget.js'; export type { StepSpend } from './protocol.js'; export { SPEC_SCHEMA_VERSION } from './spec.js'; +export { + runHostedCapabilityExtension, + loadHostedExtensionArtifacts, + type HostedExtensionArtifact, + type HostedCapabilityAuthority, + type HostedBabysitterCapability, + type RunHostedExtensionOptions, + type HostedExtensionResult, +} from './hosted-extension-isolation.js'; + export { CloudFlowError, type CloudConnectionOptions, type CloudConfigurationReason } from './cloud-http.js'; export { listCloudRuns, getCloudRunDetail, getCloudRunSteps, getCloudRunLog, diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 46df1773d..8311b6471 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -9,6 +10,7 @@ import { exportBabysitterCatalogBundle } from '../src/babysitter-catalog-export. import { addExtensionPlugin } from '../src/cli/add-extension.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; import { resolveExtensionSubmission } from '../src/flow-extension-submit.js'; +import { runHostedCapabilityExtension } from '../src/hosted-extension-isolation.js'; import { JournalClient } from '../src/journal-client.js'; import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; import { SHA_A, entriesFromDirectory, fakeGithub } from './fake-github.js'; @@ -103,6 +105,32 @@ describe('native Babysitter extension', () => { })).rejects.toMatchObject({ code: 'plugin_unsupported' }); }); + it('keeps the committed handler refused until its declared runtime release exists', async () => { + const { extension } = installed; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + let calls = 0; + await expect(runHostedCapabilityExtension({ + artifact: { + ref: extension.ref, + name: extension.name, + version: extension.version, + directory: extension.directory, + digest: extension.digest, + manifestSha256: createHash('sha256') + .update(readFileSync(join(extension.directory, 'flows-plugin.json'))).digest('hex'), + }, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_incompatible' }); + expect(calls).toBe(0); + }); + it('satisfies the #550 catalog exporter from the committed bytes, manifest unmodified', async () => { const options = { fetch: github().fetch, versions }; const bundle = await resolveExtensionSubmission(REF, options); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts new file mode 100644 index 000000000..ee3cb2140 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -0,0 +1,306 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { + loadHostedExtensionArtifacts, + runHostedCapabilityExtension, + type HostedExtensionArtifact, +} from '../src/hosted-extension-isolation.js'; +import { materializePlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +const manifest = (writes: string[] = ['cloud:babysitter-turn']) => ({ + schema: 2, + kind: 'flow-extension', + name: 'babysitter', + version: '0.2.0', + compat: { surface: '*', sdk: '*', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'babysitter.flow.ts', + extends: { handlers: true, hooks: [] }, + triggers: [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, + ], + permissions: { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes, + budget: { dollars: 1, wallclock: '5m' }, + }, + preflight: { credentials: [], servers: [] }, +}); + +const ordinaryExtension = ` +import { flow, github } from '@relayflows/surface'; +export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + const receipt = await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + if (!receipt || !['queued', 'duplicate'].includes(receipt.status)) throw new Error('bad receipt'); + f.done('success'); + }); +`; + +function descriptor(deliveryId = 'delivery-1') { + return { + event: { provider: 'github', eventType: 'pull_request.labeled', deliveryId }, + pullRequest: { owner: 'AgentWorkforce', repo: 'flows', number: 551 }, + }; +} + +async function artifact(source = ordinaryExtension, value = manifest()): Promise { + const root = mkdtempSync(join(tmpdir(), 'hosted-extension-test-')); roots.push(root); + const manifestBytes = Buffer.from(JSON.stringify(value)); + const stored = await materializePlugin(root, 'babysitter', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'babysitter.flow.ts', data: Buffer.from(source) }, + ]); + return { + ref: `github:AgentWorkforce/flows@${'a'.repeat(40)}#extensions/babysitter`, + name: 'babysitter', + version: '0.2.0', + directory: stored.directory, + digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + }; +} + +describe('hosted extension capability isolation', () => { + it('resolves locked artifacts without importing extension top-level code', async () => { + const marker = join(mkdtempSync(join(tmpdir(), 'hosted-loader-marker-')), 'imported'); + roots.push(marker.slice(0, marker.lastIndexOf('/'))); + const installed = await artifact(` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(marker)}, 'imported'); + ${ordinaryExtension} + `); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ plugins: [installed.ref] })); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + expect(await loadHostedExtensionArtifacts(join(root, 'software-factory.flow.ts'))).toEqual([installed]); + expect(() => readFileSync(marker)).toThrow(); + }); + + it.each(['queued', 'duplicate'] as const)('executes the exact capability-only handler for a %s receipt', async status => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const calls: unknown[] = []; + const result = await runHostedCapabilityExtension({ + artifact: installed, dispatch, input: descriptor(), + babysitterTurn: { + queue: async (request, authority) => { + calls.push(request); + expect(authority.dispatch).toBe(dispatch); + expect(authority.extension).toMatchObject({ name: 'babysitter', version: '0.2.0', digest: installed.digest }); + return { receiptId: 'receipt-1', status }; + }, + }, + }); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }); + + it('denies ambient credentials, host files, writes, network, subprocesses, and undeclared context verbs', async () => { + const sentinel = join(mkdtempSync(join(tmpdir(), 'hosted-sentinel-')), 'secret.txt'); + roots.push(sentinel.slice(0, sentinel.lastIndexOf('/'))); + writeFileSync(sentinel, 'host-secret'); + const escaped = `${sentinel}.escaped`; + const source = ` +import { flow, github } from '@relayflows/surface'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +const denied = async fn => { try { await fn(); return 'allowed'; } catch (error) { return error?.code ?? error?.message ?? 'denied'; } }; +const evidence = { + ambient: process.env.HOSTED_EXTENSION_TEST_SECRET, + read: await denied(() => readFileSync(${JSON.stringify(sentinel)}, 'utf8')), + write: await denied(() => writeFileSync(${JSON.stringify(escaped)}, 'escape')), + child: await denied(() => { const result = spawnSync('/usr/bin/true'); if (result.error) throw result.error; }), + network: await denied(() => fetch('https://example.com')), +}; +export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + evidence.run = await denied(() => f.run); + evidence.github = await denied(() => f.github); + evidence.mcp = await denied(() => f.mcp); + evidence.agent = await denied(() => f.agent); + if (evidence.ambient !== undefined + || ['read', 'write', 'child', 'network'].some(key => evidence[key] === 'allowed') + || ['run', 'github', 'mcp', 'agent'].some(key => !String(evidence[key]).startsWith('hosted extension context denies '))) { + throw new Error('sandbox escape was allowed'); + } + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + f.done('success'); + }); +`; + const installed = await artifact(source); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const original = process.env.HOSTED_EXTENSION_TEST_SECRET; + process.env.HOSTED_EXTENSION_TEST_SECRET = 'host-secret-env'; + try { + await runHostedCapabilityExtension({ + artifact: installed, dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, + }); + } finally { + if (original === undefined) delete process.env.HOSTED_EXTENSION_TEST_SECRET; + else process.env.HOSTED_EXTENSION_TEST_SECRET = original; + } + expect(() => readFileSync(escaped)).toThrow(); + }); + + it('blocks extra handler fields and authority-bearing receipt fields at the parent port', async () => { + const source = ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: input, sessionId: 'attacker' }); + f.done('success'); + }); + `; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runHostedCapabilityExtension({ + artifact: await artifact(source), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + + await expect(runHostedCapabilityExtension({ + artifact: await artifact(), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued', sessionId: 'host-leak' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + }); + + it('refuses unbranded or mismatched authority before the capability adapter runs', async () => { + const installed = await artifact(); + let calls = 0; + const capability = { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }; + await expect(runHostedCapabilityExtension({ + artifact: installed, + dispatch: { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' } as never, + input: descriptor(), babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runHostedCapabilityExtension({ + artifact: installed, dispatch, input: descriptor('attacker-delivery'), babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + await expect(runHostedCapabilityExtension({ + artifact: installed, dispatch, + input: { ...descriptor(), rawWebhook: { installationToken: 'must-not-enter-child' } }, + babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it('refuses broader permissions before importing extension code', async () => { + const marker = join(mkdtempSync(join(tmpdir(), 'hosted-import-marker-')), 'imported'); + roots.push(marker.slice(0, marker.lastIndexOf('/'))); + const installed = await artifact( + `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'imported'); ${ordinaryExtension}`, + manifest(['cloud:babysitter-turn', 'github:pull_request']), + ); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runHostedCapabilityExtension({ + artifact: installed, dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(() => readFileSync(marker)).toThrow(); + }); + + it('fails closed when the handler omits or repeats the single capability call', async () => { + for (const body of [ + `f.done('success')`, + `await f.capabilities.cloud.babysitterTurn.queue({ delivery: input }); await f.capabilities.cloud.babysitterTurn.queue({ delivery: input }); f.done('success')`, + ]) { + const installed = await artifact(` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { ${body}; }); + `); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runHostedCapabilityExtension({ + artifact: installed, dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } + }); + + it('treats direct protocol output as hostile and rejects completion while the adapter is pending', async () => { + const source = ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (_f, input) => { + writeSync(3, JSON.stringify({ type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } } }) + '\\n'); + writeSync(3, JSON.stringify({ type: 'result', completionReason: 'success', capabilityCalls: 1 }) + '\\n'); + await new Promise(() => {}); + }); + `; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let resolveAdapter!: (value: unknown) => void; + const adapter = new Promise(resolve => { resolveAdapter = resolve; }); + await expect(runHostedCapabilityExtension({ + artifact: await artifact(source), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => await adapter }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + resolveAdapter({ receiptId: 'too-late', status: 'queued' }); + + const nonObject = ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + writeSync(3, 'null\\n'); + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async () => {}); + `; + await expect(runHostedCapabilityExtension({ + artifact: await artifact(nonObject), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'never', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index cc9298fa2..ed8f259dd 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -26,6 +26,7 @@ "tests/authored-flow-operation.test.ts", "tests/authored-flow.test.ts", "tests/authored-agent-permissions.test.ts", + "tests/hosted-extension-isolation.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", "tests/journal-client-loopback.ts", diff --git a/packages/surface/src/cloud.ts b/packages/surface/src/cloud.ts index 38382d9e9..d97ecc0da 100644 --- a/packages/surface/src/cloud.ts +++ b/packages/surface/src/cloud.ts @@ -74,3 +74,30 @@ export interface CloudHelper { journal(input: { runId: string; as: string }): Step; }; } + +/** Delivery-only request accepted by the host-owned native Babysitter adapter. */ +export interface CloudBabysitterTurnDelivery { + readonly deliveryId: string; + readonly provider: 'github'; + readonly eventType: string; + readonly pullRequest: { + readonly owner: string; + readonly repository: string; + readonly number: number; + }; +} + +/** The only successful native-turn outcomes; refusals reject the call. */ +export interface CloudBabysitterTurnReceipt { + readonly receiptId: string; + readonly status: 'queued' | 'duplicate'; +} + +export interface CloudBabysitterTurnCapability { + queue(request: { readonly delivery: CloudBabysitterTurnDelivery }): PromiseLike; +} + +/** Capability ports are injected by a host runtime, never constructed by authored input. */ +export interface CloudCapabilities { + readonly babysitterTurn?: CloudBabysitterTurnCapability; +} diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index a9820ae76..7dd467d54 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -1,6 +1,6 @@ import type { Helpers } from "./helpers/index.js"; import type { MemoryHelper } from "./memory.js"; -import type { CloudHelper } from "./cloud.js"; +import type { CloudCapabilities, CloudHelper } from "./cloud.js"; import type { FlowCompletionReason } from "./completion.js"; import type { Step } from "./step.js"; @@ -56,6 +56,8 @@ export interface LlmOptions { * context or execute a step, so all effects remain behind the journal client. */ export interface Ctx extends Helpers { + /** Host-verified ports. Absent from direct/local runs and ordinary authored execution. */ + readonly capabilities?: { readonly cloud?: CloudCapabilities }; readonly mcp: Readonly Step>>>>; /** Command lease: milliseconds or a duration such as "5m"; default 30s, maximum 15m. */ run(command: string, options?: { timeout?: string | number }): Step; diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts index 4a6884f60..2daff08e3 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -6,6 +6,10 @@ export type { ScheduleState, WorkerSummary, CloudHelper, + CloudCapabilities, + CloudBabysitterTurnCapability, + CloudBabysitterTurnDelivery, + CloudBabysitterTurnReceipt, } from "./cloud.js"; export type { AgentOptions, AgentResult, PermissionsSpec, LlmOptions, Ctx } from "./context.js"; export { From 532de19fc3aa1abd49b9cd777871439409a1d733 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 05:25:32 -0700 Subject: [PATCH 02/80] fix(sdk): bind hosted Babysitter isolation to trusted loaders Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 21 +- docs/CLOUD.md | 12 +- evidence/babysitter-gate8-isolation/README.md | 20 +- .../verification.txt | 113 ++-- extensions/babysitter/babysitter.flow.ts | 11 +- packages/sdk/src/authored-flow-loader.ts | 34 +- .../sdk/src/hosted-extension-isolation.ts | 494 +++++++----------- packages/sdk/src/hosted-extension-protocol.ts | 165 ++++++ packages/sdk/src/hosted-extension-sandbox.ts | 174 ++++++ packages/sdk/src/index.ts | 8 + .../tests/babysitter-native-extension.test.ts | 158 +++++- .../tests/hosted-extension-isolation.test.ts | 153 ++++-- 12 files changed, 921 insertions(+), 442 deletions(-) create mode 100644 packages/sdk/src/hosted-extension-protocol.ts create mode 100644 packages/sdk/src/hosted-extension-sandbox.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 004c94795..6691bd4f2 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -19,18 +19,22 @@ exact live `babysit` label and the bound session/head. Permission declarations are not enforcement. Export success is byte verification, not execution approval. The native package source is `extensions/babysitter` (see its README for the -turn contract). It is unreleased and cannot execute through the generic -executor: #549 still refuses it. The SDK now has a separate Linux-only +turn contract). Flows 2.0.26 is published, but the package cannot execute +through the generic executor: #549 still refuses it. The SDK now has a separate Linux-only capability sandbox that injects exactly `capabilities.cloud.babysitterTurn.queue` without exposing the base context, workspace, environment credentials, network, helpers, MCP, or harnesses. It is not wired to hosted dispatch and must not be treated as enablement. The package's -`compat` requires a surface release after 2.0.25 that routes `labeled`, -`unlabeled`, and `ready_for_review`. Export it only from a reviewed, merged -commit. +`compat` requires the published 2.0.26 Surface/SDK release that routes +`labeled`, `unlabeled`, and `ready_for_review`. Export it only from the reviewed +commit pinned below. The sandbox contract is deliberately narrower than #442. It re-verifies the -content-addressed artifact and manifest, accepts only the native Babysitter +complete lock-backed installation and every manifest, binds it to the actual +base returned by `loadHostedExtensionBase` (which internally calls +`loadAuthoredFlow(..., { extensions: 'none' })`), detects +cross-extension route ambiguity, accepts only the exact published Babysitter +ref/digest/manifest and native permission profile, permission profile, imports the entry only inside Linux bubblewrap plus Node's permission model, mounts a minimal trusted Surface facade (`flow`, `github`, and `getFlowDefinition`) instead of the general helper runtime, checks @@ -45,8 +49,9 @@ persisted dispatch context, re-read live PR/label/head state, and return only rejects once with no fallback. Before replacing #549's refusal, the hosted caller must use -`loadAuthoredFlow(..., { extensions: 'none' })`, obtain raw verified artifacts -with `loadHostedExtensionArtifacts`, and call `runHostedCapabilityExtension`; +obtain opaque base and installation authorities with +`loadHostedExtensionBase` and `loadHostedExtensionArtifacts`, then call +`runHostedCapabilityExtension` with both values; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Independent review must prove this path at the exact release head. Broader per-agent-step file/network/access-preset diff --git a/docs/CLOUD.md b/docs/CLOUD.md index ceb08ebfc..f1638c75f 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -391,14 +391,18 @@ fail closed. This release also refuses a matching handler with ordinary JavaScript and their manifest permissions are not yet isolated by the runtime (gate 8 / #442). The SDK now contains a Linux-only, capability-only isolation primitive for the native Babysitter profile. Hosted callers must load -the base with `extensions: 'none'`, resolve extension bytes with -`loadHostedExtensionArtifacts` (which does not import them), then use -`runHostedCapabilityExtension`; the artifact is first imported inside a +the base with `loadHostedExtensionBase` (which internally sets +`extensions: 'none'`), resolve extension bytes with +`loadHostedExtensionArtifacts` (which does not import them), then pass both +opaque loader results to `runHostedCapabilityExtension`. The runner verifies +the complete lock-backed set, actual base compatibility, route uniqueness, and +the exact reviewed Babysitter ref/digest/manifest before the artifact is imported inside a bubblewrap mount/PID/network/user namespace with an empty credential environment and a context exposing only `capabilities.cloud.babysitterTurn.queue` plus `done`. This is a prerequisite, not enablement: the generic executor refusal remains until the Cloud adapter, -artifact provenance, and independent security review are complete. A handler's +Relay route, end-to-end canary, and independent security review are complete. +A handler's trigger is therefore a requirement, but the handler cannot execute through the generic executor yet. The deploy body carries the same list as `requirements` for Cloud to cross-check, and a declared harness Cloud cannot run yet (`gemini`) diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index d25ee577a..b0ffba24c 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -7,10 +7,11 @@ deployment. The enforced boundary is: -- load the base flow with extensions disabled and resolve extension artifacts - without importing extension JavaScript; -- reverify the content-addressed artifact, lock metadata, manifest hash, exact - immutable native source, runtime compatibility, and narrow permission profile; +- load and retain the actual base flow with extensions disabled and resolve an + opaque complete installation without importing extension JavaScript; +- reverify every content-addressed artifact, lock metadata, manifest hash, + base/runtime compatibility, and route uniqueness, then require the exact + immutable native ref/digest/manifest and narrow permission profile; - validate a symbol-branded verified dispatch and a closed normalized delivery descriptor before import; - import and execute the matching handler only inside a bubblewrap namespace @@ -21,14 +22,19 @@ The enforced boundary is: - expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and `done`, validate the exact request and `{ receiptId, status }` response in the parent, and pass the original non-serializable authority to the host adapter; +- treat descriptor 3 as hostile transport: validate every frame and both + boundary payloads, so direct writes can consume only the same exact one-shot + delivery capability already granted and cannot claim premature success; - fail closed on unknown frames, repeated/omitted calls, premature completion, - timeouts, adapter rejection, incompatible bytes, and broader permissions. + timeouts, adapter rejection, incompatible bytes, route ambiguity, forged + loader results, an unverified/composed base, and broader permissions. Cloud PR #3942 remains responsible for persisted dispatch context, live PR and label/head revalidation, authorized existing-session resolution, lineage-based deduplication, and Relay native-turn delivery. The broader per-step permission enforcement requested by #442 is not implemented here and remains a separate -gate. The committed extension declares SDK/Surface `^2.0.26`; current 2.0.25 -correctly refuses it before import. +gate. The committed extension declares SDK/Surface `^2.0.26`; an injected +2.0.25 runtime is refused before import, while the exact published 2.0.26 bytes +are exercised inside the sandbox. See `verification.txt` for the final local verification record. diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 15d7af6e6..c9a138335 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1,58 +1,63 @@ -Native Babysitter gate-8 isolation — final local verification +Native Babysitter gate-8 isolation — literal local verification Host: Linux sf-frame Date: 2026-09-22 -$ (cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts) -@relayflows/sdk typecheck: PASS -@relayflows/sdk build: PASS -@relayflows/sdk test typecheck: PASS -Test Files 4 passed (4) -Tests 134 passed (134) - hosted-extension-isolation.test.ts 9 passed - babysitter-native-extension.test.ts 32 passed - authored-flow.test.ts 34 passed - preflight.test.ts 59 passed -Exit: 0 -The focused command was rerun after the package gate installed the packed -Surface tarball into SDK node_modules; the minimal Surface facade passed without -relying on the source checkout's peer dependencies. - -$ (cd packages/sdk && /home/khaliqgant/.local/share/mise/installs/node/22.23.2/bin/node ./node_modules/vitest/vitest.mjs run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts) -Test Files 2 passed (2) -Tests 41 passed (41) -Exit: 0 - -$ npm run typecheck --prefix packages/surface -Exit: 0 - -$ npm run typecheck:regressions --prefix packages/surface +$ /usr/bin/bwrap --version && node --version && git diff --check +bubblewrap 0.12.0 +v26.8.1 + +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/babysitter-native-extension.test.ts (35 tests) 173ms + ✓ tests/hosted-extension-isolation.test.ts (12 tests) 648ms + ✓ tests/preflight.test.ts (59 tests) 82ms + ✓ tests/authored-flow.test.ts (34 tests) 742ms + + Test Files 4 passed (4) + Tests 140 passed (140) + Start at 05:24:48 + Duration 1.74s (transform 900ms, setup 0ms, collect 2.82s, tests 1.64s, environment 1ms, prepare 204ms) + +$ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test + +> @relayflows/surface@2.0.26 typecheck +> tsc --noEmit + +> @relayflows/surface@2.0.26 typecheck:regressions +> tsc -p ../../regressions/tsconfig.json && tsc -p tsconfig.test.json && node scripts/check-generated-helpers.mjs + HELPERS_GENERATED_OK airtable.ts, asana.ts, azure-blob.ts, box.ts, calendly.ts, clickup.ts, clients.ts, cloudflare.ts, confluence.ts, daytona.ts, docker-hub.ts, dropbox.ts, fathom.ts, gcp.ts, gcs.ts, github.ts, gitlab.ts, gmail.ts, google-calendar.ts, google-drive.ts, granola.ts, hubspot.ts, index.ts, intercom.ts, jira.ts, linear.ts, mailgun.ts, mixpanel.ts, neon.ts, notion.ts, onedrive.ts, pipedrive.ts, postgres.ts, posthog.ts, providers.ts, ramp.ts, recall.ts, reddit.ts, redis.ts, s3.ts, salesforce.ts, segment.ts, sendgrid.ts, sharepoint.ts, shopify.ts, shortcut.ts, slack.ts, stripe.ts, teams.ts, telegram.ts, webhook-server.ts, x.ts, zendesk.ts -Exit: 0 - -$ npm test --prefix packages/surface -Test Files 9 passed (9) -Tests 51 passed (51) -Exit: 0 - -$ bash scripts/surface-package-gate.sh -PACKED_RUNTIME_OK name=packed-runtime-consumer completionReason=success -PACKED_RUNTIME_REFUSAL_OK invalidHeaders=9 forgedHandle=refused -PACKED_TYPESCRIPT_OK -Test Files 1 passed (1) -Tests 34 passed (34) -Exit: 0 - -$ (cd packages/sdk && npm test) -Test Files 8 failed | 156 passed | 3 skipped (167) -Tests 14 failed | 2603 passed | 25 skipped (2642) -Errors 1 error -Exit: 1 - -The new hosted-extension isolation suite passed inside the full run. The full -run's failures were outside the changed boundary and host-dependent: Bun 1.4.2 -versus the suite's pinned 1.4.0; missing kernel/target debug or release -relayflowd paths while the toolchain wrapper built elsewhere; unavailable real -Claude analyzer auth; one missing live connection file; one existing standalone -bundle second-build refusal; and one helper replay filesystem write error. -These failures are reported rather than suppressed; the focused final suites -above are the acceptance evidence for this slice. + +> @relayflows/surface@2.0.26 test +> bun run build && tsc -p tsconfig.test.json && vitest run + +$ tsc + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/surface + + ✓ tests/declined.test.ts (1 test) 2ms + ✓ tests/triggers-github-events.test.ts (2 tests) 3ms + ✓ tests/helpers.snapshot.test.ts (1 test) 249ms + ✓ tests/triggers-all-providers.test.ts (4 tests) 36ms + ✓ tests/slack-block-kit.test.ts (5 tests) 3ms + ✓ tests/provider-triggers.test.ts (3 tests) 5ms + ✓ tests/triggers.test.ts (4 tests) 5ms + ✓ tests/flow.test.ts (24 tests) 10ms + ✓ tests/schedule.test.ts (7 tests) 4714ms + ✓ schedule.cron > measures a cron's longest quiet period so a silence budget can be declared honestly 4696ms + + Test Files 9 passed (9) + Tests 51 passed (51) + Start at 05:18:30 + Duration 5.21s (transform 551ms, setup 0ms, collect 2.23s, tests 5.03s, environment 1ms, prepare 499ms) diff --git a/extensions/babysitter/babysitter.flow.ts b/extensions/babysitter/babysitter.flow.ts index 8b078ad69..35f21d45a 100644 --- a/extensions/babysitter/babysitter.flow.ts +++ b/extensions/babysitter/babysitter.flow.ts @@ -11,13 +11,14 @@ interface TurnQueue { } /** - * The declared write, as Cloud's capability adapter spells it. `Ctx` types the - * optional port, but ordinary runtimes do not inject it; a runtime without it - * must fail the run, never skip the turn silently. The adapter, not this entry, - * holds workspace, activation, and the verified delivery authority. + * The declared write, as Cloud's capability adapter spells it. The SDK `Ctx` + * has no `capabilities` field yet; a runtime without it must fail the run, + * never skip the turn silently. The adapter, not this entry, holds workspace, + * activation, and the verified delivery authority. */ function turnQueue(f: Ctx): TurnQueue { - const port = f.capabilities?.cloud?.babysitterTurn; + const capabilities = (f as unknown as { readonly capabilities?: { readonly cloud?: { readonly babysitterTurn?: unknown } } }).capabilities; + const port = capabilities?.cloud?.babysitterTurn; if (typeof port !== 'object' || port === null || typeof (port as Partial).queue !== 'function') { throw new Error('babysitter: this runtime does not provide the cloud:babysitter-turn write.'); } diff --git a/packages/sdk/src/authored-flow-loader.ts b/packages/sdk/src/authored-flow-loader.ts index 5a7db01a4..d17be8cae 100644 --- a/packages/sdk/src/authored-flow-loader.ts +++ b/packages/sdk/src/authored-flow-loader.ts @@ -12,6 +12,9 @@ import { import { canonicalize } from './canonical.js'; import { composeDefinition, loadFlowExtensions, parseHooksExport, type ImportedFlow, type LoadedFlowExtension } from './flow-extension-loader.js'; import type { RuntimeVersions } from './flow-extension-compat.js'; +import { PluginError } from './plugin-manifest.js'; + +const HOSTED_BASE_AUTHORITY = new WeakSet(); export class AuthoredFlowLoadError extends Error { constructor(message: string, readonly kind: 'invalid_spec' | 'use_not_found' | 'use_invalid' | 'use_cycle' = 'invalid_spec') { @@ -67,6 +70,35 @@ export interface LoadedAuthoredFlowNode { readonly use: readonly string[]; } +/** + * Project the actual base identity only from a flow this loader imported with + * extensions disabled. Structural lookalikes and composed flows are refused. + */ +export function hostedExtensionBaseFromLoadedFlow( + value: unknown, +): Readonly<{ name: string; version?: string }> { + if (typeof value !== 'object' || value === null || !HOSTED_BASE_AUTHORITY.has(value) + || !Array.isArray((value as Partial).extensions) + || (value as LoadedAuthoredFlow).extensions.length !== 0) { + throw new PluginError( + 'plugin_incompatible', + 'Hosted extension base must be the result of loadAuthoredFlow(..., { extensions: \'none\' }).', + ); + } + const loaded = value as LoadedAuthoredFlow; + const definition = loaded.getDefinition(loaded.handle); + return Object.freeze({ + name: definition.name, + ...(definition.header.version === undefined ? {} : { version: definition.header.version }), + }); +} + +function authorizedLoadedFlow(value: LoadedAuthoredFlow): LoadedAuthoredFlow { + const frozen = Object.freeze(value); + HOSTED_BASE_AUTHORITY.add(frozen); + return frozen; +} + /** Import and validate a direct-run module without executing its authored body. */ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOptions = {}): Promise { const loaded = new Map(); @@ -115,7 +147,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp const root = await visit(resolve(path), true); const graph = [...loaded.values()]; if (options.extensions === 'none') { - return Object.freeze({ sourcePath: root.path, handle: root.handle, getDefinition: root.getDefinition, + return authorizedLoadedFlow({ sourcePath: root.path, handle: root.handle, getDefinition: root.getDefinition, surfaceAuthority: root.surfaceAuthority, graph: Object.freeze(graph), extensions: Object.freeze([]) }); } // Extensions are verified (lock, digest, manifest hash, compat) before their diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 6f65ab151..98d24d6f8 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,14 +1,13 @@ import { createHash } from 'node:crypto'; -import { createRequire } from 'node:module'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; -import { dirname, join, parse, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawn } from 'node:child_process'; -import type { Readable, Writable } from 'node:stream'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; +import { + hostedExtensionBaseFromLoadedFlow, + loadAuthoredFlow, +} from './authored-flow-loader.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; -import { assertCompatible, runtimeVersions } from './flow-extension-compat.js'; +import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; import { hostedExtensionDispatchIdentity, type HostedExtensionDispatch, @@ -17,13 +16,18 @@ import { PluginError } from './plugin-manifest.js'; import { findPluginProject } from './plugin-loader.js'; import { reconcileDeclaredExtensions } from './plugin-lock.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; -import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; +import { + boundedJsonSnapshot, + type HostedExtensionProtocolResult, +} from './hosted-extension-protocol.js'; +import { runHostedExtensionSandbox } from './hosted-extension-sandbox.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; -const MAX_FRAME_BYTES = 256 * 1024; -const MAX_STDERR_BYTES = 16 * 1024; -const DEFAULT_TIMEOUT_MS = 10_000; -const BABYSITTER_REF = /^github:AgentWorkforce\/flows@[a-f0-9]{40}#extensions\/babysitter$/; +const BABYSITTER_REF = 'github:AgentWorkforce/flows@d3ee3b55ae636518dd4ad562aca5bae9c99f1a05#extensions/babysitter'; +const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; +const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; +const HOSTED_INSTALLATION_AUTHORITY = new WeakSet(); +const HOSTED_BASE_AUTHORITY = new WeakSet(); const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; @@ -45,6 +49,15 @@ export interface HostedExtensionArtifact { readonly manifestSha256: string; } +export interface HostedExtensionInstallation { + readonly artifacts: readonly HostedExtensionArtifact[]; +} + +export interface HostedExtensionBase { + readonly name: string; + readonly version?: string; +} + export interface HostedCapabilityAuthority { readonly dispatch: HostedExtensionDispatch; readonly extension: Readonly<{ @@ -60,7 +73,10 @@ export interface HostedBabysitterCapability { } export interface RunHostedExtensionOptions { - readonly artifact: HostedExtensionArtifact; + /** Complete, lock-ordered set returned by loadHostedExtensionArtifacts. */ + readonly installation: HostedExtensionInstallation; + /** Identity read from the base loaded with extensions disabled. */ + readonly base: HostedExtensionBase; readonly dispatch: HostedExtensionDispatch; /** Host-normalized delivery descriptor. Its event identity must equal dispatch. */ readonly input: unknown; @@ -72,10 +88,7 @@ export interface RunHostedExtensionOptions { readonly nodePath?: string; } -export interface HostedExtensionResult { - readonly completionReason: 'success'; - readonly capabilityCalls: 1; -} +export type HostedExtensionResult = HostedExtensionProtocolResult; /** * Resolve installed extension artifacts without importing their JavaScript. @@ -85,9 +98,9 @@ export interface HostedExtensionResult { */ export async function loadHostedExtensionArtifacts( flowPath: string, -): Promise { +): Promise { const root = findPluginProject(dirname(resolve(flowPath))); - if (root === undefined) return Object.freeze([]); + if (root === undefined) return installation([]); const artifacts: HostedExtensionArtifact[] = []; for (const { ref, entry } of reconcileDeclaredExtensions(root)) { const directory = pluginStoreDirectory(root, entry.name, entry.digest); @@ -101,7 +114,25 @@ export async function loadHostedExtensionArtifacts( manifestSha256: entry.manifestSha256, })); } - return Object.freeze(artifacts); + return installation(artifacts); +} + +/** Load and brand the actual base with extension importing explicitly disabled. */ +export async function loadHostedExtensionBase(flowPath: string): Promise { + const loaded = await loadAuthoredFlow(flowPath, { extensions: 'none' }); + const identity = hostedExtensionBaseFromLoadedFlow(loaded); + const value = Object.freeze({ + name: identity.name, + ...(identity.version === undefined ? {} : { version: identity.version }), + }); + HOSTED_BASE_AUTHORITY.add(value); + return value; +} + +function installation(artifacts: readonly HostedExtensionArtifact[]): HostedExtensionInstallation { + const value = Object.freeze({ artifacts: Object.freeze([...artifacts]) }); + HOSTED_INSTALLATION_AUTHORITY.add(value); + return value; } /** @@ -118,80 +149,110 @@ export async function loadHostedExtensionArtifacts( export async function runHostedCapabilityExtension( options: RunHostedExtensionOptions, ): Promise { - if (process.platform !== 'linux') return unsupported('hosted extension isolation requires Linux'); - const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); - const node = executable(options.nodePath ?? process.execPath, 'Node'); - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { - return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); + const identity = hostedExtensionDispatchIdentity(options.dispatch); + if (typeof options.base !== 'object' || options.base === null + || !HOSTED_BASE_AUTHORITY.has(options.base)) { + throw new PluginError( + 'plugin_incompatible', + 'Hosted extension base authority is malformed; use loadHostedExtensionBase.', + ); } + const { artifact, manifest } = await selectHostedExtensionForRuntime( + options.installation, + options.base, + identity, + runtimeVersions(), + ); + return await runVerifiedNativeExtensionSandbox({ ...options, artifact, manifest }); +} - const manifest = await verifiedManifest(options.artifact); - assertCapabilityOnlyManifest(manifest); - const versions = runtimeVersions(); - assertCompatible(manifest, versions); +interface RunVerifiedNativeExtensionOptions { + readonly artifact: HostedExtensionArtifact; + readonly manifest: FlowExtensionManifest; + readonly dispatch: HostedExtensionDispatch; + readonly input: unknown; + readonly babysitterTurn: HostedBabysitterCapability; + readonly timeoutMs?: number; + readonly bubblewrapPath?: string; + readonly nodePath?: string; +} + +/** @internal Security-harness seam; not exported from the SDK package root. */ +export async function runVerifiedNativeExtensionSandbox( + options: RunVerifiedNativeExtensionOptions, +): Promise { + assertCapabilityOnlyManifest(options.manifest); const identity = hostedExtensionDispatchIdentity(options.dispatch); - assertManifestRoutes(manifest, identity); + assertManifestRoutes(options.manifest, identity); const normalizedInput = babysitterInput(options.input, options.dispatch); - const entryPath = resolve(options.artifact.directory, manifest.entry); - if (!entryPath.startsWith(`${resolve(options.artifact.directory)}/`)) { - throw new PluginError('plugin_path_invalid', `${manifest.entry}: hosted extension entry escapes its artifact.`); - } - const surfaceRoot = resolveSurfaceRoot(versions.surface); - - const runtimeDirectory = await mkdtemp(join(tmpdir(), 'flows-hosted-extension-')); - const runner = join(runtimeDirectory, 'runner.mjs'); - const surfaceFacade = join(runtimeDirectory, 'surface'); - try { - await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); - await writeSurfaceFacade(surfaceFacade); - const args = sandboxArguments({ - node, runner, extension: realpathSync(options.artifact.directory), surfaceFacade, surfaceRoot, - }); - const child = spawn(bwrap, args, { - cwd: '/', env: {}, - stdio: ['pipe', 'ignore', 'pipe', 'pipe'], - }); - const protocol = child.stdio[3] as Readable; - const stdin = child.stdin as Writable; - const stderr = child.stderr as Readable; - const authority: HostedCapabilityAuthority = Object.freeze({ - dispatch: options.dispatch, - extension: Object.freeze({ - name: manifest.name, - version: manifest.version, - ref: options.artifact.ref, - digest: options.artifact.digest, - }), - }); - return await exchange(child, protocol, stdin, stderr, timeoutMs, { - type: 'execute', - entry: `/extension/src/${manifest.entry}`, - surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', - capability: HOSTED_WRITE, - identity, - input: normalizedInput, - }, async request => babysitterReceipt(await options.babysitterTurn.queue( + const versions = runtimeVersions(); + const authority: HostedCapabilityAuthority = Object.freeze({ + dispatch: options.dispatch, + extension: Object.freeze({ + name: options.manifest.name, + version: options.manifest.version, + ref: options.artifact.ref, + digest: options.artifact.digest, + }), + }); + return await runHostedExtensionSandbox({ + artifactDirectory: options.artifact.directory, + entry: options.manifest.entry, + surfaceVersion: versions.surface, + identity, + input: normalizedInput, + timeoutMs: options.timeoutMs, + bubblewrapPath: options.bubblewrapPath, + nodePath: options.nodePath, + invoke: async request => babysitterReceipt(await options.babysitterTurn.queue( babysitterRequest(request, normalizedInput, options.dispatch), authority, - ))); - } finally { - await rm(runtimeDirectory, { recursive: true, force: true }); + )), + }); +} + +/** @internal Selection seam used by compatibility/security regression tests. */ +export async function selectHostedExtensionForRuntime( + value: HostedExtensionInstallation, + base: HostedExtensionBase, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, + versions: Readonly<{ sdk: string; surface: string }>, +): Promise<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> { + if (typeof value !== 'object' || value === null + || !HOSTED_INSTALLATION_AUTHORITY.has(value) || !Array.isArray(value.artifacts)) { + throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); + } + if (typeof base !== 'object' || base === null || typeof base.name !== 'string' + || (base.version !== undefined && typeof base.version !== 'string')) { + throw new PluginError('plugin_incompatible', 'Hosted base identity is malformed.'); + } + const matches: Array<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> = []; + for (const artifact of value.artifacts) { + const manifest = await verifiedManifest(artifact); + assertCompatible(manifest, versions); + assertBaseCompatible(manifest, base); + if (hostedManifestRoutes(manifest, identity)) matches.push({ artifact, manifest }); + } + if (matches.length > 1) { + throw new PluginError( + 'plugin_event_ambiguous', + `Hosted event matches multiple extension manifests (${matches.map(match => match.manifest.name).join(', ')}).`, + ); + } + const selected = matches[0]; + if (selected === undefined || selected.artifact.ref !== BABYSITTER_REF) { + throw new PluginError('plugin_event_unroutable', 'Hosted event does not route to the pinned native Babysitter extension.'); } + await assertPinnedBabysitter(selected.artifact); + assertCapabilityOnlyManifest(selected.manifest); + return selected; } async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { - if (!BABYSITTER_REF.test(artifact.ref)) { - throw new PluginError('plugin_source_invalid', `${artifact.ref}: hosted capability isolation accepts only the immutable native Babysitter source.`); - } if (!/^[a-f0-9]{64}$/.test(artifact.digest) || !/^[a-f0-9]{64}$/.test(artifact.manifestSha256)) { throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extension digests are malformed.`); } await verifyStoredPlugin(artifact.directory, artifact.digest); - const payload = JSON.parse((await readFile(join(artifact.directory, 'manifest.json'))).toString('utf8')) as Array<{ path?: unknown }>; - if (payload.some(file => typeof file.path === 'string' && (file.path === 'node_modules' || file.path.startsWith('node_modules/')))) { - throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); - } const bytes = await readFile(join(artifact.directory, 'flows-plugin.json')); if (sha256(bytes) !== artifact.manifestSha256) { throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json differs from the lockfile's manifest hash.`); @@ -209,6 +270,23 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { + if (artifact.ref !== BABYSITTER_REF || artifact.name !== 'babysitter' || artifact.version !== '0.2.0' + || artifact.digest !== BABYSITTER_DIGEST || artifact.manifestSha256 !== BABYSITTER_MANIFEST_SHA256) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted capability isolation accepts only the reviewed native Babysitter artifact.', + ); + } + const payload = JSON.parse( + (await readFile(join(artifact.directory, 'manifest.json'))).toString('utf8'), + ) as Array<{ path?: unknown }>; + if (payload.some(file => typeof file.path === 'string' + && (file.path === 'node_modules' || file.path.startsWith('node_modules/')))) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); + } +} + function assertCapabilityOnlyManifest(manifest: FlowExtensionManifest): void { const expected = { integrations: ['github'], harnesses: ['codex'], mcp: [], writes: [HOSTED_WRITE], @@ -241,14 +319,22 @@ function assertManifestRoutes( manifest: FlowExtensionManifest, identity: { readonly provider: string; readonly event: string; readonly action?: string }, ): void { - const matches = manifest.triggers.filter(trigger => trigger.provider === identity.provider - && trigger.event === identity.event - && (trigger.actions.length === 0 ? identity.action === undefined : identity.action !== undefined && trigger.actions.includes(identity.action))); - if (matches.length !== 1) { + if (!hostedManifestRoutes(manifest, identity)) { throw new PluginError('plugin_event_unroutable', `${manifest.name}: hosted event is not declared exactly once.`); } } +/** Generic handlers overlap action-specific deliveries, matching the SDK handler router. */ +export function hostedManifestRoutes( + manifest: Pick, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, +): boolean { + return manifest.triggers.some(trigger => trigger.provider === identity.provider + && trigger.event === identity.event + && (trigger.actions.length === 0 + || (identity.action !== undefined && trigger.actions.includes(identity.action)))); +} + function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { const top = exactRecord(input, 'Hosted extension input', ['event', 'pullRequest']); const event = exactRecord(top.event, 'Hosted extension event', ['provider', 'eventType', 'deliveryId']); @@ -270,28 +356,7 @@ function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unk && (typeof pullRequest.headSha !== 'string' || !HEAD_SHA.test(pullRequest.headSha)))) { throw new PluginError('plugin_event_unroutable', 'Hosted extension input does not match the verified GitHub delivery.'); } - return jsonSnapshot(input, 'hosted extension input'); -} - -function resolveSurfaceRoot(expectedVersion: string): string { - let resolved: string; - try { resolved = realpathSync(createRequire(import.meta.url).resolve('@relayflows/surface')); } - catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } - let directory = dirname(resolved); - const root = parse(directory).root; - while (directory !== root) { - const packageJson = join(directory, 'package.json'); - if (existsSync(packageJson)) { - try { - const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; - if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { - return realpathSync(directory); - } - } catch { /* keep walking */ } - } - directory = dirname(directory); - } - return unsupported('hosted extension resolved an invalid @relayflows/surface package'); + return boundedJsonSnapshot(input, 'hosted extension input'); } function babysitterRequest(value: unknown, input: unknown, dispatch: HostedExtensionDispatch): unknown { @@ -311,12 +376,17 @@ function babysitterRequest(value: unknown, input: unknown, dispatch: HostedExten || pullRequest.number !== inputPullRequest.number) { throw new PluginError('plugin_event_unroutable', 'Babysitter capability request does not match verified delivery input.'); } - return jsonSnapshot(value, 'Babysitter capability request'); + return boundedJsonSnapshot(value, 'Babysitter capability request'); } function babysitterReceipt(value: unknown): unknown { - const receipt = exactRecord(value, 'Babysitter capability receipt', ['receiptId', 'status']); - if (typeof receipt.receiptId !== 'string' || !RECEIPT_ID.test(receipt.receiptId) + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); + } + const receipt = value as Record; + const keys = Object.keys(receipt).sort(); + if (keys.length !== 2 || keys[0] !== 'receiptId' || keys[1] !== 'status' + || typeof receipt.receiptId !== 'string' || !RECEIPT_ID.test(receipt.receiptId) || (receipt.status !== 'queued' && receipt.status !== 'duplicate')) { throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); } @@ -355,210 +425,6 @@ function optionalRecord( return object; } -async function writeSurfaceFacade(directory: string): Promise { - await mkdir(directory); - await Promise.all([ - writeFile(join(directory, 'package.json'), JSON.stringify({ - name: '@relayflows/surface', - type: 'module', - exports: { '.': './index.js', './runtime': './runtime.js' }, - }), { mode: 0o400, flag: 'wx' }), - writeFile( - join(directory, 'index.js'), - "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", - { mode: 0o400, flag: 'wx' }, - ), - writeFile( - join(directory, 'runtime.js'), - "export { getFlowDefinition } from './dist/flow.js';\n", - { mode: 0o400, flag: 'wx' }, - ), - ]); -} - -function sandboxArguments(input: { - node: string; - runner: string; - extension: string; - surfaceFacade: string; - surfaceRoot: string; -}): string[] { - const args = [ - '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', - '--dir', '/usr', - ]; - // Bind only dynamic-library roots, not all of /usr (which commonly includes - // compilers, shells, package managers, and occasionally source worktrees). - for (const path of ['/usr/lib', '/usr/lib64', '/lib', '/lib64']) { - if (!existsSync(path)) continue; - args.push('--ro-bind', realpathSync(path), path); - } - args.push( - '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', - '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', - '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', - '--dir', '/extension/node_modules/@relayflows/surface', - '--ro-bind', join(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', - '--ro-bind', join(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', - '--ro-bind', join(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', - '--dir', '/extension/node_modules/@relayflows/surface/dist', - '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', - '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', - ...surfaceRuntimeMounts(input.surfaceRoot), - '--ro-bind', input.extension, '/extension/src', - '--chdir', '/extension/src', - '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', - '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', - '--allow-fs-read=/runtime', '--allow-fs-read=/extension', '/runtime/runner.mjs', - ); - return args; -} - -function surfaceRuntimeMounts(surfaceRoot: string): string[] { - const files = [ - 'flow.js', - 'helpers/providers.js', - 'provider-trigger.js', - 'schedule.js', - 'triggers.js', - 'triggers/github.js', - ]; - return files.flatMap(file => { - const source = realpathSync(join(surfaceRoot, 'dist', file)); - return ['--ro-bind', source, `/extension/node_modules/@relayflows/surface/dist/${file}`]; - }); -} - -function executable(path: string, name: string): string { - let real: string; - try { real = realpathSync(path); } - catch { return unsupported(`${name} is unavailable`); } - if (!lstatSync(real).isFile()) return unsupported(`${name} is not a regular file`); - return real; -} - -async function exchange( - child: ReturnType, - protocol: Readable, - stdin: Writable, - stderr: Readable, - timeoutMs: number, - request: unknown, - invoke: (request: unknown) => Promise, -): Promise { - let buffer = ''; - let stderrText = ''; - let calls = 0; - stderr.setEncoding('utf8'); - stderr.on('data', chunk => { stderrText = (stderrText + String(chunk)).slice(-MAX_STDERR_BYTES); }); - protocol.setEncoding('utf8'); - const timeout = setTimeout(() => child.kill('SIGKILL'), timeoutMs); - timeout.unref(); - return await new Promise((resolvePromise, rejectPromise) => { - let settled = false; - let capabilityState: 'none' | 'pending' | 'completed' | 'failed' = 'none'; - const finish = (error?: Error, result?: HostedExtensionResult) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - stdin.end(); - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - if (error !== undefined) rejectPromise(error); - else resolvePromise(result!); - }; - const refuse = (message: string) => { - child.kill('SIGKILL'); - finish(new PluginError('plugin_unsupported', message)); - }; - stdin.on('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension capability channel closed.'))); - protocol.on('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension protocol channel failed.'))); - protocol.on('data', chunk => { - buffer += String(chunk); - if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); - for (;;) { - const end = buffer.indexOf('\n'); - if (end < 0) break; - const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); - let message: Record; - try { - const parsed = JSON.parse(line) as unknown; - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return refuse('Hosted extension emitted a non-object protocol frame.'); - } - message = parsed as Record; - } - catch { return refuse('Hosted extension emitted malformed protocol data.'); } - if (message.type === 'capability') { - if (!hasExactKeys(message, ['type', 'id', 'name', 'request'])) { - return refuse('Hosted extension emitted a malformed capability frame.'); - } - calls += 1; - if (calls !== 1 || capabilityState !== 'none' || message.name !== HOSTED_WRITE || message.id !== 1) { - return refuse('Hosted extension requested an undeclared or repeated capability.'); - } - capabilityState = 'pending'; - void invoke(message.request).then( - value => { - if (settled) return; - try { - const snapshot = jsonSnapshot(value, 'hosted capability result'); - capabilityState = 'completed'; - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); - } catch (error) { - capabilityState = 'failed'; - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: error instanceof Error ? error.message : String(error) })}\n`); - } - }, - error => { - if (settled) return; - capabilityState = 'failed'; - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: error instanceof Error ? error.message : String(error) })}\n`); - }, - ); - } else if (message.type === 'result') { - if (!hasExactKeys(message, ['type', 'completionReason', 'capabilityCalls']) - || calls !== 1 || capabilityState !== 'completed' - || message.completionReason !== 'success' || message.capabilityCalls !== 1) { - return refuse('Hosted extension reported a completion without exactly one capability call.'); - } - finish(undefined, Object.freeze({ completionReason: 'success', capabilityCalls: 1 })); - } else if (message.type === 'error') { - if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { - return refuse('Hosted extension emitted a malformed error frame.'); - } - finish(new PluginError('plugin_unsupported', `Hosted extension failed: ${String(message.message).slice(0, 8192)}`)); - } else return refuse('Hosted extension emitted an unknown protocol message.'); - } - }); - child.once('error', () => finish(new PluginError('plugin_unsupported', 'Hosted extension sandbox could not start.'))); - child.once('close', code => { - if (!settled) finish(new PluginError( - 'plugin_unsupported', - `Hosted extension sandbox exited without a valid completion (exit ${code ?? 'signal'})${stderrText === '' ? '' : `: ${stderrText}`}`, - )); - }); - stdin.write(`${JSON.stringify(request)}\n`); - }); -} - -function hasExactKeys(value: Record, keys: readonly string[]): boolean { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -} - -function jsonSnapshot(value: unknown, what: string): unknown { - const encoded = JSON.stringify(value); - if (encoded === undefined || Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { - throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); - } - return JSON.parse(encoded) as unknown; -} - function sha256(value: Uint8Array): string { return createHash('sha256').update(value).digest('hex'); } - -function unsupported(message: string): never { - throw new PluginError('plugin_unsupported', message); -} diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts new file mode 100644 index 000000000..14b183468 --- /dev/null +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -0,0 +1,165 @@ +import type { ChildProcess } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; +import { PluginError } from './plugin-manifest.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const MAX_FRAME_BYTES = 256 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; + +export interface HostedExtensionProtocolResult { + readonly completionReason: 'success'; + readonly capabilityCalls: 1; +} + +/** A bounded JSON copy, stripped of prototypes and behavior. */ +export function boundedJsonSnapshot(value: unknown, what: string): unknown { + const encoded = JSON.stringify(value); + if (encoded === undefined || Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { + throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); + } + return JSON.parse(encoded) as unknown; +} + +/** + * Exchange hostile child frames for one parent-owned capability invocation. + * Descriptor 3 is transport, never authority: every frame and both boundary + * payloads are validated by the parent. A child writing descriptor 3 directly + * can request only the same exact, single capability its context exposes. + */ +export async function exchangeHostedExtension( + child: ChildProcess, + protocol: Readable, + stdin: Writable, + stderr: Readable, + timeoutMs: number, + request: unknown, + invoke: (request: unknown) => Promise, +): Promise { + let buffer = ''; + let stderrText = ''; + let calls = 0; + stderr.setEncoding('utf8'); + stderr.on('data', chunk => { stderrText = (stderrText + String(chunk)).slice(-MAX_STDERR_BYTES); }); + protocol.setEncoding('utf8'); + + return await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let capabilityState: 'none' | 'pending' | 'completed' | 'failed' = 'none'; + let capabilityError: Error | undefined; + let deferredProtocolError: Error | undefined; + const finish = (error?: Error, result?: HostedExtensionProtocolResult) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + stdin.end(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + if (error !== undefined) rejectPromise(error); + else resolvePromise(result!); + }; + const failure = (error: unknown): Error => error instanceof Error + ? error + : new PluginError('plugin_unsupported', 'Hosted capability rejected with a non-error value.'); + const refuse = (message: string) => { + const error = new PluginError('plugin_unsupported', message); + child.kill('SIGKILL'); + if (capabilityState === 'pending') { + deferredProtocolError ??= error; + return; + } + finish(capabilityError ?? error); + }; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + finish(new PluginError( + 'plugin_unsupported', + capabilityState === 'pending' + ? 'Hosted capability outcome is in doubt after the sandbox timeout.' + : 'Hosted extension sandbox timed out.', + )); + }, timeoutMs); + timeout.unref(); + + stdin.on('error', () => refuse('Hosted extension capability channel closed.')); + protocol.on('error', () => refuse('Hosted extension protocol channel failed.')); + protocol.on('data', chunk => { + buffer += String(chunk); + if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); + for (;;) { + const end = buffer.indexOf('\n'); + if (end < 0) break; + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let message: Record; + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return refuse('Hosted extension emitted a non-object protocol frame.'); + } + message = parsed as Record; + } catch { return refuse('Hosted extension emitted malformed protocol data.'); } + + if (message.type === 'capability') { + if (!hasExactKeys(message, ['type', 'id', 'name', 'request'])) { + return refuse('Hosted extension emitted a malformed capability frame.'); + } + calls += 1; + if (calls !== 1 || capabilityState !== 'none' || message.name !== HOSTED_WRITE || message.id !== 1) { + return refuse('Hosted extension requested an undeclared or repeated capability.'); + } + capabilityState = 'pending'; + void invoke(message.request).then( + value => { + if (settled) return; + try { + const snapshot = boundedJsonSnapshot(value, 'hosted capability result'); + capabilityState = 'completed'; + if (deferredProtocolError !== undefined) return finish(deferredProtocolError); + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); + } catch (error) { + capabilityError = failure(error); + capabilityState = 'failed'; + if (deferredProtocolError !== undefined) return finish(capabilityError); + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + } + }, + error => { + if (settled) return; + capabilityError = failure(error); + capabilityState = 'failed'; + if (deferredProtocolError !== undefined) return finish(capabilityError); + stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + }, + ); + } else if (message.type === 'result') { + if (!hasExactKeys(message, ['type', 'completionReason', 'capabilityCalls']) + || calls !== 1 || capabilityState !== 'completed' + || message.completionReason !== 'success' || message.capabilityCalls !== 1) { + return refuse('Hosted extension reported a completion without exactly one capability call.'); + } + finish(undefined, Object.freeze({ completionReason: 'success', capabilityCalls: 1 })); + } else if (message.type === 'error') { + if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { + return refuse('Hosted extension emitted a malformed error frame.'); + } + finish(capabilityError ?? new PluginError( + 'plugin_unsupported', + `Hosted extension failed: ${message.message.slice(0, 8192)}`, + )); + } else return refuse('Hosted extension emitted an unknown protocol message.'); + } + }); + child.once('error', () => refuse('Hosted extension sandbox could not start.')); + child.once('close', code => { + if (!settled) refuse( + `Hosted extension sandbox exited without a valid completion (exit ${code ?? 'signal'})` + + (stderrText === '' ? '' : `: ${stderrText}`), + ); + }); + stdin.write(`${JSON.stringify(request)}\n`); + }); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts new file mode 100644 index 000000000..b947f782e --- /dev/null +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -0,0 +1,174 @@ +import { createRequire } from 'node:module'; +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, parse, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; +import { PluginError } from './plugin-manifest.js'; +import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; +import { + exchangeHostedExtension, + type HostedExtensionProtocolResult, +} from './hosted-extension-protocol.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const DEFAULT_TIMEOUT_MS = 10_000; + +export interface RunHostedExtensionSandboxOptions { + readonly artifactDirectory: string; + readonly entry: string; + readonly surfaceVersion: string; + readonly identity: { readonly provider: string; readonly event: string; readonly action?: string }; + readonly input: unknown; + readonly invoke: (request: unknown) => Promise; + readonly timeoutMs?: number; + readonly bubblewrapPath?: string; + readonly nodePath?: string; +} + +export async function runHostedExtensionSandbox( + options: RunHostedExtensionSandboxOptions, +): Promise { + if (process.platform !== 'linux') return unsupported('hosted extension isolation requires Linux'); + if (options.nodePath === undefined && !supportsHostedSandboxFlags(process.versions.node)) { + return unsupported(`hosted extension isolation does not support Node ${process.versions.node}`); + } + const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); + const node = executable(options.nodePath ?? process.execPath, 'Node'); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); + } + const entryPath = resolve(options.artifactDirectory, options.entry); + if (!entryPath.startsWith(`${resolve(options.artifactDirectory)}/`)) { + throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); + } + const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion); + const runtimeDirectory = await mkdtemp(join(tmpdir(), 'flows-hosted-extension-')); + const runner = join(runtimeDirectory, 'runner.mjs'); + const surfaceFacade = join(runtimeDirectory, 'surface'); + try { + await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); + await writeSurfaceFacade(surfaceFacade); + const args = sandboxArguments({ + node, + runner, + extension: realpathSync(options.artifactDirectory), + surfaceFacade, + surfaceRoot, + }); + const child = spawn(bwrap, args, { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'] }); + return await exchangeHostedExtension( + child, + child.stdio[3] as Readable, + child.stdin as Writable, + child.stderr as Readable, + timeoutMs, + { + type: 'execute', + entry: `/extension/src/${options.entry}`, + surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', + capability: HOSTED_WRITE, + identity: options.identity, + input: options.input, + }, + options.invoke, + ); + } finally { + await rm(runtimeDirectory, { recursive: true, force: true }); + } +} + +function resolveSurfaceRoot(expectedVersion: string): string { + let resolved: string; + try { resolved = realpathSync(createRequire(import.meta.url).resolve('@relayflows/surface')); } + catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } + let directory = dirname(resolved); + const root = parse(directory).root; + while (directory !== root) { + const packageJson = join(directory, 'package.json'); + if (existsSync(packageJson)) { + try { + const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; + if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { + return realpathSync(directory); + } + } catch { /* keep walking */ } + } + directory = dirname(directory); + } + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); +} + +async function writeSurfaceFacade(directory: string): Promise { + await mkdir(directory); + await Promise.all([ + writeFile(join(directory, 'package.json'), JSON.stringify({ + name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, + }), { mode: 0o400, flag: 'wx' }), + writeFile(join(directory, 'index.js'), + "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", + { mode: 0o400, flag: 'wx' }), + writeFile(join(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", + { mode: 0o400, flag: 'wx' }), + ]); +} + +function sandboxArguments(input: { + node: string; runner: string; extension: string; surfaceFacade: string; surfaceRoot: string; +}): string[] { + const args = [ + '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', '--dir', '/usr', + ]; + for (const path of ['/usr/lib', '/usr/lib64', '/lib', '/lib64']) { + if (existsSync(path)) args.push('--ro-bind', realpathSync(path), path); + } + args.push( + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', + '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', + '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', + '--dir', '/extension/node_modules/@relayflows/surface', + '--ro-bind', join(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', + '--ro-bind', join(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', + '--ro-bind', join(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', + '--dir', '/extension/node_modules/@relayflows/surface/dist', + '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', + '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', + ...surfaceRuntimeMounts(input.surfaceRoot), + '--ro-bind', input.extension, '/extension/src', '--chdir', '/extension/src', + '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', + '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', + '--allow-fs-read=/runtime', '--allow-fs-read=/extension', '/runtime/runner.mjs', + ); + return args; +} + +function surfaceRuntimeMounts(surfaceRoot: string): string[] { + return [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', 'triggers.js', 'triggers/github.js', + ].flatMap(file => [ + '--ro-bind', realpathSync(join(surfaceRoot, 'dist', file)), + `/extension/node_modules/@relayflows/surface/dist/${file}`, + ]); +} + +function executable(path: string, name: string): string { + let real: string; + try { real = realpathSync(path); } + catch { return unsupported(`${name} is unavailable`); } + if (!lstatSync(real).isFile()) return unsupported(`${name} is not a regular file`); + return real; +} + +export function supportsHostedSandboxFlags(version: string): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-|$)/.exec(version); + if (match === null) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + return major >= 24 || (major === 23 && minor >= 5) || (major === 22 && minor >= 13); +} + +function unsupported(message: string): never { + throw new PluginError('plugin_unsupported', message); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2aedb5c7b..c36a47af3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -61,12 +61,20 @@ export { SPEC_SCHEMA_VERSION } from './spec.js'; export { runHostedCapabilityExtension, loadHostedExtensionArtifacts, + loadHostedExtensionBase, type HostedExtensionArtifact, + type HostedExtensionInstallation, + type HostedExtensionBase, type HostedCapabilityAuthority, type HostedBabysitterCapability, type RunHostedExtensionOptions, type HostedExtensionResult, } from './hosted-extension-isolation.js'; +export { + hostedExtensionDispatchFromVerifiedDelivery, + type HostedExtensionDispatch, + type HostedEventIdentity, +} from './flow-extension-loader.js'; export { CloudFlowError, type CloudConnectionOptions, type CloudConfigurationReason } from './cloud-http.js'; export { diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 8311b6471..89e158b52 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -1,5 +1,4 @@ -import { createHash } from 'node:crypto'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { Ctx } from '@relayflows/surface'; @@ -10,13 +9,22 @@ import { exportBabysitterCatalogBundle } from '../src/babysitter-catalog-export. import { addExtensionPlugin } from '../src/cli/add-extension.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; import { resolveExtensionSubmission } from '../src/flow-extension-submit.js'; -import { runHostedCapabilityExtension } from '../src/hosted-extension-isolation.js'; +import { + loadHostedExtensionArtifacts, + loadHostedExtensionBase, + runHostedCapabilityExtension, + selectHostedExtensionForRuntime, +} from '../src/hosted-extension-isolation.js'; import { JournalClient } from '../src/journal-client.js'; +import { materializePlugin } from '../src/plugin-store.js'; import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; -import { SHA_A, entriesFromDirectory, fakeGithub } from './fake-github.js'; +import { entriesFromDirectory, fakeGithub } from './fake-github.js'; const PATH = 'extensions/babysitter'; -const REF = `github:AgentWorkforce/flows@${SHA_A}#${PATH}`; +const NATIVE_SHA = 'd3ee3b55ae636518dd4ad562aca5bae9c99f1a05'; +const REF = `github:AgentWorkforce/flows@${NATIVE_SHA}#${PATH}`; +const DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; +const MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; // pull_request.labeled, .unlabeled and .ready_for_review route only in the surface after 2.0.25. const versions = { sdk: '2.0.26', surface: '2.0.26' }; const now = () => new Date('2026-09-22T12:00:00Z'); @@ -31,7 +39,7 @@ const dirs: string[] = []; afterAll(() => dirs.splice(0).forEach(p => rmSync(p, { recursive: true, force: true }))); function github() { - return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [SHA_A]: { entries } } } }); + return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [NATIVE_SHA]: { entries } } } }); } /** Install the committed bytes the way an operator would, then load the composition. */ @@ -48,7 +56,18 @@ async function composed() { const io = { stdout: () => {}, stderr: (s: string) => { throw new Error(s); } }; expect(await addExtensionPlugin(REF, io, { cwd, fetch: github().fetch, now, versions })).toBe(0); const loaded = await loadAuthoredFlow(join(cwd, 'software-factory.flow.ts'), { versions }); - return { loaded, extension: loaded.extensions[0]! }; + const flowPath = join(cwd, 'software-factory.flow.ts'); + const baseLoaded = await loadAuthoredFlow(flowPath, { extensions: 'none', versions }); + const baseDefinition = baseLoaded.getDefinition(baseLoaded.handle); + const hostedBase = await loadHostedExtensionBase(flowPath); + return { + cwd, + flowPath, + loaded, + hostedBase, + extension: loaded.extensions[0]!, + base: { name: baseDefinition.name, version: baseDefinition.header.version }, + }; } function subscriptionOf(trigger: unknown): string { @@ -105,30 +124,130 @@ describe('native Babysitter extension', () => { })).rejects.toMatchObject({ code: 'plugin_unsupported' }); }); - it('keeps the committed handler refused until its declared runtime release exists', async () => { - const { extension } = installed; + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'runs the exact published 2.0.26 native bytes in the isolated capability path', async () => { + const installation = await loadHostedExtensionArtifacts(installed.flowPath); + expect(installation.artifacts).toHaveLength(1); + expect(installation.artifacts[0]).toMatchObject({ ref: REF, digest: DIGEST, manifestSha256: MANIFEST_SHA256 }); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + const calls: unknown[] = []; + await expect(runHostedCapabilityExtension({ + installation, + base: installed.hostedBase, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async (request, authority) => { + calls.push(request); + expect(authority.dispatch).toBe(dispatch); + expect(authority.extension).toEqual({ + name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST, + }); + return { receiptId: `bst_${'a'.repeat(64)}`, status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'gh-delivery-7', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }); + + it('refuses the real bytes on 2.0.25 before execution and admits them on 2.0.26', async () => { + const installation = await loadHostedExtensionArtifacts(installed.flowPath); + await expect(selectHostedExtensionForRuntime( + installation, installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + { sdk: '2.0.25', surface: '2.0.25' }, + )).rejects.toMatchObject({ code: 'plugin_incompatible' }); + await expect(selectHostedExtensionForRuntime( + installation, installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + versions, + )).resolves.toMatchObject({ artifact: { ref: REF, digest: DIGEST } }); + }); + + it('binds the pinned artifact to the actual base and complete installed route set', async () => { + const installation = await loadHostedExtensionArtifacts(installed.flowPath); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', }); let calls = 0; await expect(runHostedCapabilityExtension({ - artifact: { - ref: extension.ref, - name: extension.name, - version: extension.version, - directory: extension.directory, - digest: extension.digest, - manifestSha256: createHash('sha256') - .update(readFileSync(join(extension.directory, 'flows-plugin.json'))).digest('hex'), - }, + installation, + base: installed.loaded as never, dispatch, input: descriptor('pull_request.labeled'), babysitterTurn: { queue: async () => { calls += 1; - return { receiptId: 'receipt-1', status: 'queued' }; + return { receiptId: 'never', status: 'queued' }; } }, })).rejects.toMatchObject({ code: 'plugin_incompatible' }); expect(calls).toBe(0); + + await expect(runHostedCapabilityExtension({ + installation, + base: installed.base as never, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_incompatible' }); + expect(calls).toBe(0); + + await expect(runHostedCapabilityExtension({ + installation: { artifacts: installation.artifacts } as never, + base: installed.hostedBase, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(calls).toBe(0); + }); + + it('refuses a second installed extension that overlaps an action-specific route', async () => { + const overlapRef = `github:AgentWorkforce/other@${'b'.repeat(40)}#extensions/overlap`; + const overlapManifest = { + schema: 2, kind: 'flow-extension', name: 'overlap', version: '1.0.0', + compat: { surface: '^2.0.26', sdk: '^2.0.26', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'overlap.flow.ts', extends: { handlers: true, hooks: [] }, + triggers: [{ provider: 'github', event: 'pull_request', actions: [] }], + permissions: { integrations: ['github'], harnesses: [], mcp: [], writes: [], budget: { dollars: 0.01, wallclock: '1m' } }, + preflight: { credentials: [], servers: [] }, + }; + const manifestBytes = Buffer.from(JSON.stringify(overlapManifest)); + const stored = await materializePlugin(installed.cwd, 'overlap', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'overlap.flow.ts', data: Buffer.from('export default {};') }, + ]); + const configPath = join(installed.cwd, 'flows.json'); + const lockPath = join(installed.cwd, 'flows.lock.json'); + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { plugins: string[] }; + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as { version: 2; plugins: Array> }; + config.plugins.push(overlapRef); + lock.plugins.push({ + name: 'overlap', kind: 'flow-extension', version: '1.0.0', + source: { host: 'github', owner: 'AgentWorkforce', repo: 'other', sha: 'b'.repeat(40), path: 'extensions/overlap' }, + digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + order: 2, + resolvedAt: '2026-09-22T12:00:00.000Z', + }); + writeFileSync(configPath, JSON.stringify(config)); + writeFileSync(lockPath, JSON.stringify(lock)); + + const installation = await loadHostedExtensionArtifacts(installed.flowPath); + await expect(selectHostedExtensionForRuntime( + installation, + installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + versions, + )).rejects.toMatchObject({ code: 'plugin_event_ambiguous' }); }); it('satisfies the #550 catalog exporter from the committed bytes, manifest unmodified', async () => { @@ -225,3 +344,4 @@ describe('native Babysitter extension', () => { expect(c.done).toEqual(['declined']); }); }); +import { createHash } from 'node:crypto'; diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index ee3cb2140..13a4df809 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -3,12 +3,19 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { github } from '@relayflows/surface'; import { + extensionHandlerForHostedDispatch, + hostedExtensionDispatchFromVerifiedDelivery, +} from '../src/flow-extension-loader.js'; +import { + hostedManifestRoutes, loadHostedExtensionArtifacts, - runHostedCapabilityExtension, + runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; +import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; +import { supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; import { materializePlugin } from '../src/plugin-store.js'; const roots: string[] = []; @@ -97,7 +104,7 @@ describe('hosted extension capability isolation', () => { order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', }], })); - expect(await loadHostedExtensionArtifacts(join(root, 'software-factory.flow.ts'))).toEqual([installed]); + expect((await loadHostedExtensionArtifacts(join(root, 'software-factory.flow.ts'))).artifacts).toEqual([installed]); expect(() => readFileSync(marker)).toThrow(); }); @@ -107,8 +114,8 @@ describe('hosted extension capability isolation', () => { provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); const calls: unknown[] = []; - const result = await runHostedCapabilityExtension({ - artifact: installed, dispatch, input: descriptor(), + const result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), babysitterTurn: { queue: async (request, authority) => { calls.push(request); @@ -125,6 +132,32 @@ describe('hosted extension capability isolation', () => { } }]); }); + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { + const source = ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + try { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + } catch (error) { + if (error?.message !== 'hosted capability refused') throw new Error('host refusal leaked into child'); + throw error; + } + }); + `; + const refusal = Object.assign(new Error('private Cloud policy detail'), { code: 'cloud_policy_refusal' }); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { throw refusal; } }, + })).rejects.toBe(refusal); + }); + it('denies ambient credentials, host files, writes, network, subprocesses, and undeclared context verbs', async () => { const sentinel = join(mkdtempSync(join(tmpdir(), 'hosted-sentinel-')), 'secret.txt'); roots.push(sentinel.slice(0, sentinel.lastIndexOf('/'))); @@ -169,8 +202,8 @@ export default flow('babysitter', async f => f.done('declined')) const original = process.env.HOSTED_EXTENSION_TEST_SECRET; process.env.HOSTED_EXTENSION_TEST_SECRET = 'host-secret-env'; try { - await runHostedCapabilityExtension({ - artifact: installed, dispatch, input: descriptor(), + await runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, }); } finally { @@ -193,14 +226,14 @@ export default flow('babysitter', async f => f.done('declined')) provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); let calls = 0; - await expect(runHostedCapabilityExtension({ - artifact: await artifact(source), dispatch, input: descriptor(), + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); expect(calls).toBe(0); - await expect(runHostedCapabilityExtension({ - artifact: await artifact(), dispatch, input: descriptor(), + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued', sessionId: 'host-leak' }) }, })).rejects.toMatchObject({ code: 'plugin_unsupported' }); }); @@ -209,8 +242,8 @@ export default flow('babysitter', async f => f.done('declined')) const installed = await artifact(); let calls = 0; const capability = { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }; - await expect(runHostedCapabilityExtension({ - artifact: installed, + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch: { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' } as never, input: descriptor(), babysitterTurn: capability, })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); @@ -218,11 +251,11 @@ export default flow('babysitter', async f => f.done('declined')) const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); - await expect(runHostedCapabilityExtension({ - artifact: installed, dispatch, input: descriptor('attacker-delivery'), babysitterTurn: capability, + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor('attacker-delivery'), babysitterTurn: capability, })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); - await expect(runHostedCapabilityExtension({ - artifact: installed, dispatch, + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: { ...descriptor(), rawWebhook: { installationToken: 'must-not-enter-child' } }, babysitterTurn: capability, })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); @@ -239,8 +272,8 @@ export default flow('babysitter', async f => f.done('declined')) const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); - await expect(runHostedCapabilityExtension({ - artifact: installed, dispatch, input: descriptor(), + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest(['cloud:babysitter-turn', 'github:pull_request'])), dispatch, input: descriptor(), babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, })).rejects.toMatchObject({ code: 'plugin_unsupported' }); expect(() => readFileSync(marker)).toThrow(); @@ -249,7 +282,14 @@ export default flow('babysitter', async f => f.done('declined')) it('fails closed when the handler omits or repeats the single capability call', async () => { for (const body of [ `f.done('success')`, - `await f.capabilities.cloud.babysitterTurn.queue({ delivery: input }); await f.capabilities.cloud.babysitterTurn.queue({ delivery: input }); f.done('success')`, + `await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } })`, + `const request = { delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }; await f.capabilities.cloud.babysitterTurn.queue(request); await f.capabilities.cloud.babysitterTurn.queue(request); f.done('success')`, ]) { const installed = await artifact(` import { flow, github } from '@relayflows/surface'; @@ -259,8 +299,8 @@ export default flow('babysitter', async f => f.done('declined')) const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); - await expect(runHostedCapabilityExtension({ - artifact: installed, dispatch, input: descriptor(), + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, })).rejects.toMatchObject({ code: 'plugin_unsupported' }); } @@ -285,11 +325,20 @@ export default flow('babysitter', async f => f.done('declined')) }); let resolveAdapter!: (value: unknown) => void; const adapter = new Promise(resolve => { resolveAdapter = resolve; }); - await expect(runHostedCapabilityExtension({ - artifact: await artifact(source), dispatch, input: descriptor(), - babysitterTurn: { queue: async () => await adapter }, + let directFrameCalls = 0; + setTimeout(() => resolveAdapter({ receiptId: 'settled-after-refusal', status: 'queued' }), 20); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + timeoutMs: 1_000, + babysitterTurn: { queue: async () => { + directFrameCalls += 1; + return await adapter; + } }, })).rejects.toMatchObject({ code: 'plugin_unsupported' }); - resolveAdapter({ receiptId: 'too-late', status: 'queued' }); + // Descriptor 3 is hostile transport, not extra authority: an exact direct + // frame can consume the already-granted call once, but cannot forge a + // second call or claim success before the host outcome settles. + expect(directFrameCalls).toBe(1); const nonObject = ` import { writeSync } from 'node:fs'; @@ -298,9 +347,53 @@ export default flow('babysitter', async f => f.done('declined')) export default flow('babysitter', async f => f.done('declined')) .on(github.pull_request('labeled'), async () => {}); `; - await expect(runHostedCapabilityExtension({ - artifact: await artifact(nonObject), dispatch, input: descriptor(), - babysitterTurn: { queue: async () => ({ receiptId: 'never', status: 'queued' }) }, + let invalidFrameCalls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(nonObject), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { + invalidFrameCalls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(invalidFrameCalls).toBe(0); + }); + + it('accepts only Node releases that implement every sandbox flag', () => { + expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); + expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); + expect(supportsHostedSandboxFlags('23.4.0')).toBe(false); + expect(supportsHostedSandboxFlags('23.5.0')).toBe(true); + expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); + expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); + }); + + it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { + const body = async () => {}; + const specific = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const duplicate = { name: 'duplicate', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const generic = { name: 'generic', handlers: [{ trigger: github.pull_request(), body }] }; + const labeled = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const edited = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.edited', deliveryId: 'delivery-2', + }); + expect(extensionHandlerForHostedDispatch(labeled, [specific])?.extension.name).toBe('specific'); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + )).toBe(true); + expect(extensionHandlerForHostedDispatch(edited, [specific])).toBeUndefined(); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'edited' }, + )).toBe(false); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, duplicate])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, generic])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch({ + provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); }); }); From 670f59d262b59526eb1e34d1ad38fc1131741ae2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 05:42:18 -0700 Subject: [PATCH 03/80] fix(sdk): await hosted capability outcomes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 7 + evidence/babysitter-gate8-isolation/README.md | 7 + .../verification.txt | 14 +- packages/sdk/src/hosted-extension-protocol.ts | 9 +- .../tests/hosted-extension-isolation.test.ts | 139 +++++++++++++++--- 5 files changed, 143 insertions(+), 33 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 6691bd4f2..fee3eb835 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -48,6 +48,13 @@ persisted dispatch context, re-read live PR/label/head state, and return only `{ receiptId, status: 'queued' | 'duplicate' }`. Refusal or in-doubt transport rejects once with no fallback. +Only the parent validator is a security boundary. The isolated entry can write +its inherited protocol descriptor directly and bypass child-side routing, +context, call-count, and completion checks. Parent validation therefore treats +every frame as hostile, permits at most the one exact delivery already bound to +the branded dispatch, and waits for the adapter's authoritative outcome before +settling any premature child terminal frame. + Before replacing #549's refusal, the hosted caller must use obtain opaque base and installation authorities with `loadHostedExtensionBase` and `loadHostedExtensionArtifacts`, then call diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index b0ffba24c..2072019be 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -29,6 +29,13 @@ The enforced boundary is: timeouts, adapter rejection, incompatible bytes, route ambiguity, forged loader results, an unverified/composed base, and broader permissions. +The parent validations are the security boundary. Child-side handler routing, +the context proxy, its once-only counter, and `done('success')` are correctness +checks only: hostile code may bypass all of them by writing descriptor 3. The +parent therefore enforces the exact delivery, one-call limit, terminal ordering, +and authoritative adapter outcome independently, including when a child emits +an error immediately after its request. + Cloud PR #3942 remains responsible for persisted dispatch context, live PR and label/head revalidation, authorized existing-session resolution, lineage-based deduplication, and Relay native-turn delivery. The broader per-step permission diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index c9a138335..9b3748293 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,15 +19,15 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/babysitter-native-extension.test.ts (35 tests) 173ms - ✓ tests/hosted-extension-isolation.test.ts (12 tests) 648ms - ✓ tests/preflight.test.ts (59 tests) 82ms - ✓ tests/authored-flow.test.ts (34 tests) 742ms + ✓ tests/babysitter-native-extension.test.ts (35 tests) 179ms + ✓ tests/preflight.test.ts (59 tests) 54ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 1062ms + ✓ tests/authored-flow.test.ts (34 tests) 737ms Test Files 4 passed (4) - Tests 140 passed (140) - Start at 05:24:48 - Duration 1.74s (transform 900ms, setup 0ms, collect 2.82s, tests 1.64s, environment 1ms, prepare 204ms) + Tests 147 passed (147) + Start at 05:40:59 + Duration 1.65s (transform 882ms, setup 0ms, collect 2.58s, tests 2.03s, environment 1ms, prepare 203ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 14b183468..0da13b7d2 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -140,10 +140,15 @@ export async function exchangeHostedExtension( if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { return refuse('Hosted extension emitted a malformed error frame.'); } - finish(capabilityError ?? new PluginError( + const error = new PluginError( 'plugin_unsupported', `Hosted extension failed: ${message.message.slice(0, 8192)}`, - )); + ); + if (capabilityState === 'pending') { + deferredProtocolError ??= error; + return; + } + finish(capabilityError ?? error); } else return refuse('Hosted extension emitted an unknown protocol message.'); } }); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 13a4df809..7736af7a5 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -4,10 +4,8 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { github } from '@relayflows/surface'; -import { - extensionHandlerForHostedDispatch, - hostedExtensionDispatchFromVerifiedDelivery, -} from '../src/flow-extension-loader.js'; +import { extensionHandlerForHostedDispatch } from '../src/flow-extension-loader.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { hostedManifestRoutes, loadHostedExtensionArtifacts, @@ -81,6 +79,29 @@ async function artifact(source = ordinaryExtension, value = manifest()): Promise }; } +function capabilityFrame(overrides: Record = {}): Record { + return { + type: 'capability', id: 1, name: 'cloud:babysitter-turn', + request: { delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + ...overrides, + } }, + }; +} + +/** Emit raw parent-protocol frames during import, before any handler can run. */ +function hostileImport(frames: readonly unknown[]): string { + const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); + return ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + ${writes} + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async () => { await new Promise(() => {}); }); + `; +} + describe('hosted extension capability isolation', () => { it('resolves locked artifacts without importing extension top-level code', async () => { const marker = join(mkdtempSync(join(tmpdir(), 'hosted-loader-marker-')), 'imported'); @@ -307,19 +328,10 @@ export default flow('babysitter', async f => f.done('declined')) }); it('treats direct protocol output as hostile and rejects completion while the adapter is pending', async () => { - const source = ` - import { writeSync } from 'node:fs'; - import { flow, github } from '@relayflows/surface'; - export default flow('babysitter', async f => f.done('declined')) - .on(github.pull_request('labeled'), async (_f, input) => { - writeSync(3, JSON.stringify({ type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: { - deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, - pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, - } } }) + '\\n'); - writeSync(3, JSON.stringify({ type: 'result', completionReason: 'success', capabilityCalls: 1 }) + '\\n'); - await new Promise(() => {}); - }); - `; + const source = hostileImport([ + capabilityFrame(), + { type: 'result', completionReason: 'success', capabilityCalls: 1 }, + ]); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); @@ -340,13 +352,7 @@ export default flow('babysitter', async f => f.done('declined')) // second call or claim success before the host outcome settles. expect(directFrameCalls).toBe(1); - const nonObject = ` - import { writeSync } from 'node:fs'; - import { flow, github } from '@relayflows/surface'; - writeSync(3, 'null\\n'); - export default flow('babysitter', async f => f.done('declined')) - .on(github.pull_request('labeled'), async () => {}); - `; + const nonObject = hostileImport([null]); let invalidFrameCalls = 0; await expect(runVerifiedNativeExtensionSandbox({ artifact: await artifact(nonObject), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), @@ -358,6 +364,91 @@ export default flow('babysitter', async f => f.done('declined')) expect(invalidFrameCalls).toBe(0); }); + it.each([ + ['different PR', capabilityFrame({ pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 999 } })], + ['different delivery', capabilityFrame({ deliveryId: 'delivery-2' })], + ['different event', capabilityFrame({ eventType: 'pull_request.opened' })], + ])('rejects an import-time %s frame with zero adapter calls', async (_case, frame) => { + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([frame, { type: 'error', message: 'forged terminal' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it('rejects a forged result before any capability call', async () => { + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([{ type: 'result', completionReason: 'success', capabilityCalls: 1 }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it('rejects two forged calls after the authoritative first outcome settles', async () => { + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { + calls += 1; + await new Promise(resolve => setTimeout(resolve, 20)); + return { receiptId: 'settled', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(1); + }); + + it.each(['reject', 'resolve'] as const)( + 'waits for a pending adapter to %s after a forged child error', async outcome => { + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let settle!: (value: unknown) => void; + let reject!: (error: Error) => void; + const adapter = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let completed = false; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), { type: 'error', message: 'premature' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { + markInvoked(); + return await adapter; + } }, + }).finally(() => { completed = true; }); + const observed = run.then(() => undefined, error => error as Error); + await invoked; + await new Promise(resolve => setTimeout(resolve, 20)); + expect(completed).toBe(false); + if (outcome === 'reject') reject(refusal); + else settle({ receiptId: 'settled', status: 'queued' }); + const error = await observed; + if (outcome === 'reject') expect(error).toBe(refusal); + else expect(error).toMatchObject({ code: 'plugin_unsupported' }); + }, + ); + it('accepts only Node releases that implement every sandbox flag', () => { expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); From eda1ce80188099f3258ddbb93eeb3e13c8d48a8f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 05:53:14 -0700 Subject: [PATCH 04/80] fix(sdk): bind hosted authorities to one flow Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 11 +++--- evidence/babysitter-gate8-isolation/README.md | 3 +- .../verification.txt | 12 +++---- .../sdk/src/hosted-extension-isolation.ts | 34 +++++++++++++++---- .../tests/babysitter-native-extension.test.ts | 13 +++++++ .../tests/hosted-extension-isolation.test.ts | 4 ++- 6 files changed, 58 insertions(+), 19 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index fee3eb835..85f51bce4 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -55,14 +55,15 @@ every frame as hostile, permits at most the one exact delivery already bound to the branded dispatch, and waits for the adapter's authoritative outcome before settling any premature child terminal frame. -Before replacing #549's refusal, the hosted caller must use -obtain opaque base and installation authorities with +Before replacing #549's refusal, the hosted caller must obtain opaque base and +installation authorities for the same canonical flow path with `loadHostedExtensionBase` and `loadHostedExtensionArtifacts`, then call `runHostedCapabilityExtension` with both values; using the ordinary compose loader would import extension top-level JavaScript -in the host before the sandbox exists. Independent review must prove this path -at the exact release head. Broader per-agent-step file/network/access-preset -enforcement remains open in #442 and is not claimed by this slice. +in the host before the sandbox exists. Cross-project pairing and structural +lookalikes fail before import. Independent review must prove this path at the +exact release head. Broader per-agent-step file/network/access-preset enforcement +remains open in #442 and is not claimed by this slice. ## Export reviewed bytes diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 2072019be..0666e36c9 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -8,7 +8,8 @@ deployment. The enforced boundary is: - load and retain the actual base flow with extensions disabled and resolve an - opaque complete installation without importing extension JavaScript; + opaque complete installation for the same canonical flow path without + importing extension JavaScript; cross-project authority pairing refuses; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 9b3748293..26f319650 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,15 +19,15 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/babysitter-native-extension.test.ts (35 tests) 179ms - ✓ tests/preflight.test.ts (59 tests) 54ms - ✓ tests/hosted-extension-isolation.test.ts (19 tests) 1062ms - ✓ tests/authored-flow.test.ts (34 tests) 737ms + ✓ tests/preflight.test.ts (59 tests) 83ms + ✓ tests/babysitter-native-extension.test.ts (35 tests) 216ms + ✓ tests/authored-flow.test.ts (34 tests) 725ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 1047ms Test Files 4 passed (4) Tests 147 passed (147) - Start at 05:40:59 - Duration 1.65s (transform 882ms, setup 0ms, collect 2.58s, tests 2.03s, environment 1ms, prepare 203ms) + Start at 05:52:39 + Duration 2.13s (transform 856ms, setup 0ms, collect 3.21s, tests 2.07s, environment 1ms, prepare 223ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 98d24d6f8..cfc76a0ac 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; +import { readFile, realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; import { @@ -28,6 +28,8 @@ const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2 const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; const HOSTED_INSTALLATION_AUTHORITY = new WeakSet(); const HOSTED_BASE_AUTHORITY = new WeakSet(); +const HOSTED_INSTALLATION_ORIGIN = new WeakMap(); +const HOSTED_BASE_ORIGIN = new WeakMap(); const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; @@ -99,8 +101,9 @@ export type HostedExtensionResult = HostedExtensionProtocolResult; export async function loadHostedExtensionArtifacts( flowPath: string, ): Promise { - const root = findPluginProject(dirname(resolve(flowPath))); - if (root === undefined) return installation([]); + const origin = await realpath(resolve(flowPath)); + const root = findPluginProject(dirname(origin)); + if (root === undefined) return installation([], origin); const artifacts: HostedExtensionArtifact[] = []; for (const { ref, entry } of reconcileDeclaredExtensions(root)) { const directory = pluginStoreDirectory(root, entry.name, entry.digest); @@ -114,24 +117,30 @@ export async function loadHostedExtensionArtifacts( manifestSha256: entry.manifestSha256, })); } - return installation(artifacts); + return installation(artifacts, origin); } /** Load and brand the actual base with extension importing explicitly disabled. */ export async function loadHostedExtensionBase(flowPath: string): Promise { - const loaded = await loadAuthoredFlow(flowPath, { extensions: 'none' }); + const origin = await realpath(resolve(flowPath)); + const loaded = await loadAuthoredFlow(origin, { extensions: 'none' }); const identity = hostedExtensionBaseFromLoadedFlow(loaded); const value = Object.freeze({ name: identity.name, ...(identity.version === undefined ? {} : { version: identity.version }), }); HOSTED_BASE_AUTHORITY.add(value); + HOSTED_BASE_ORIGIN.set(value, origin); return value; } -function installation(artifacts: readonly HostedExtensionArtifact[]): HostedExtensionInstallation { +function installation( + artifacts: readonly HostedExtensionArtifact[], + origin: string, +): HostedExtensionInstallation { const value = Object.freeze({ artifacts: Object.freeze([...artifacts]) }); HOSTED_INSTALLATION_AUTHORITY.add(value); + HOSTED_INSTALLATION_ORIGIN.set(value, origin); return value; } @@ -157,6 +166,19 @@ export async function runHostedCapabilityExtension( 'Hosted extension base authority is malformed; use loadHostedExtensionBase.', ); } + if (typeof options.installation !== 'object' || options.installation === null + || !HOSTED_INSTALLATION_AUTHORITY.has(options.installation)) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension installation authority is malformed; use loadHostedExtensionArtifacts.', + ); + } + if (HOSTED_BASE_ORIGIN.get(options.base) !== HOSTED_INSTALLATION_ORIGIN.get(options.installation)) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension base and installation must originate from the same flow.', + ); + } const { artifact, manifest } = await selectHostedExtensionForRuntime( options.installation, options.base, diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 89e158b52..4176a3d28 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -208,6 +208,19 @@ describe('native Babysitter extension', () => { } }, })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); expect(calls).toBe(0); + + const otherProject = await composed(); + await expect(runHostedCapabilityExtension({ + installation, + base: otherProject.hostedBase, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(calls).toBe(0); }); it('refuses a second installed extension that overlaps an action-specific route', async () => { diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 7736af7a5..9d678728a 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -125,7 +125,9 @@ describe('hosted extension capability isolation', () => { order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', }], })); - expect((await loadHostedExtensionArtifacts(join(root, 'software-factory.flow.ts'))).artifacts).toEqual([installed]); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + expect((await loadHostedExtensionArtifacts(flowPath)).artifacts).toEqual([installed]); expect(() => readFileSync(marker)).toThrow(); }); From 30c2276ccd10ef4557f50a77bcefe0f01c32f5b1 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 06:00:42 -0700 Subject: [PATCH 05/80] fix(sdk): preserve hosted adapter rejection Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 3 ++- evidence/babysitter-gate8-isolation/README.md | 2 +- .../verification.txt | 12 ++++----- packages/sdk/src/hosted-extension-protocol.ts | 4 +-- .../tests/hosted-extension-isolation.test.ts | 26 +++++++++++++++++++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 85f51bce4..98f925efa 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -53,7 +53,8 @@ its inherited protocol descriptor directly and bypass child-side routing, context, call-count, and completion checks. Parent validation therefore treats every frame as hostile, permits at most the one exact delivery already bound to the branded dispatch, and waits for the adapter's authoritative outcome before -settling any premature child terminal frame. +settling any premature child terminal frame. An authoritative adapter rejection +settles immediately with its original typed error even if the child hangs. Before replacing #549's refusal, the hosted caller must obtain opaque base and installation authorities for the same canonical flow path with diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 0666e36c9..33601b92e 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -35,7 +35,7 @@ the context proxy, its once-only counter, and `done('success')` are correctness checks only: hostile code may bypass all of them by writing descriptor 3. The parent therefore enforces the exact delivery, one-call limit, terminal ordering, and authoritative adapter outcome independently, including when a child emits -an error immediately after its request. +an error immediately after its request or hangs after a typed adapter rejection. Cloud PR #3942 remains responsible for persisted dispatch context, live PR and label/head revalidation, authorized existing-session resolution, lineage-based diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 26f319650..5260d2567 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -20,14 +20,14 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk ✓ tests/preflight.test.ts (59 tests) 83ms - ✓ tests/babysitter-native-extension.test.ts (35 tests) 216ms - ✓ tests/authored-flow.test.ts (34 tests) 725ms - ✓ tests/hosted-extension-isolation.test.ts (19 tests) 1047ms + ✓ tests/babysitter-native-extension.test.ts (35 tests) 248ms + ✓ tests/authored-flow.test.ts (34 tests) 692ms + ✓ tests/hosted-extension-isolation.test.ts (20 tests) 1092ms Test Files 4 passed (4) - Tests 147 passed (147) - Start at 05:52:39 - Duration 2.13s (transform 856ms, setup 0ms, collect 3.21s, tests 2.07s, environment 1ms, prepare 223ms) + Tests 148 passed (148) + Start at 06:00:11 + Duration 2.21s (transform 951ms, setup 0ms, collect 3.31s, tests 2.11s, environment 1ms, prepare 208ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 0da13b7d2..954423b0e 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -117,16 +117,16 @@ export async function exchangeHostedExtension( } catch (error) { capabilityError = failure(error); capabilityState = 'failed'; - if (deferredProtocolError !== undefined) return finish(capabilityError); stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + finish(capabilityError); } }, error => { if (settled) return; capabilityError = failure(error); capabilityState = 'failed'; - if (deferredProtocolError !== undefined) return finish(capabilityError); stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + finish(capabilityError); }, ); } else if (message.type === 'result') { diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 9d678728a..f3db98c6a 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -451,6 +451,32 @@ export default flow('babysitter', async f => f.done('declined')) }, ); + it('returns a typed adapter rejection even when the hostile child hangs', async () => { + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let rejectAdapter!: (error: Error) => void; + const adapter = new Promise((_resolve, reject) => { rejectAdapter = reject; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let calls = 0; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 100, + babysitterTurn: { queue: async () => { + calls += 1; + markInvoked(); + return await adapter; + } }, + }); + const observed = run.then(() => undefined, error => error as Error); + await invoked; + rejectAdapter(refusal); + expect(await observed).toBe(refusal); + expect(calls).toBe(1); + }); + it('accepts only Node releases that implement every sandbox flag', () => { expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); From 72fc05644d118caa8658f35c9cb2e395cfd2c6db Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 06:07:23 -0700 Subject: [PATCH 06/80] fix(sdk): mount verified hosted artifact snapshot Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 9 +++-- evidence/babysitter-gate8-isolation/README.md | 4 +- .../verification.txt | 14 +++---- .../sdk/src/hosted-extension-isolation.ts | 1 + packages/sdk/src/hosted-extension-sandbox.ts | 16 +++++++- .../tests/hosted-extension-isolation.test.ts | 37 ++++++++++++++++++- 6 files changed, 68 insertions(+), 13 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 98f925efa..812b6a5cf 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -62,9 +62,12 @@ installation authorities for the same canonical flow path with `runHostedCapabilityExtension` with both values; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Cross-project pairing and structural -lookalikes fail before import. Independent review must prove this path at the -exact release head. Broader per-agent-step file/network/access-preset enforcement -remains open in #442 and is not claimed by this slice. +lookalikes fail before import. The selected store bytes are copied into a +private snapshot whose digest is recomputed before bubblewrap mounts it, so a +later live-store replacement cannot alter imported code. Independent review +must prove this path at the exact release head. Broader per-agent-step +file/network/access-preset enforcement remains open in #442 and is not claimed +by this slice. ## Export reviewed bytes diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 33601b92e..b86fc27b1 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -12,7 +12,9 @@ The enforced boundary is: importing extension JavaScript; cross-project authority pairing refuses; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact - immutable native ref/digest/manifest and narrow permission profile; + immutable native ref/digest/manifest and narrow permission profile; copy the + selected bytes into a private snapshot, recompute its digest, and mount only + that snapshot so later store replacement cannot change executed code; - validate a symbol-branded verified dispatch and a closed normalized delivery descriptor before import; - import and execute the matching handler only inside a bubblewrap namespace diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 5260d2567..d315a90bb 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,15 +19,15 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/preflight.test.ts (59 tests) 83ms - ✓ tests/babysitter-native-extension.test.ts (35 tests) 248ms - ✓ tests/authored-flow.test.ts (34 tests) 692ms - ✓ tests/hosted-extension-isolation.test.ts (20 tests) 1092ms + ✓ tests/preflight.test.ts (59 tests) 54ms + ✓ tests/babysitter-native-extension.test.ts (35 tests) 246ms + ✓ tests/authored-flow.test.ts (34 tests) 728ms + ✓ tests/hosted-extension-isolation.test.ts (21 tests) 1120ms Test Files 4 passed (4) - Tests 148 passed (148) - Start at 06:00:11 - Duration 2.21s (transform 951ms, setup 0ms, collect 3.31s, tests 2.11s, environment 1ms, prepare 208ms) + Tests 149 passed (149) + Start at 06:06:51 + Duration 2.15s (transform 808ms, setup 0ms, collect 3.01s, tests 2.15s, environment 1ms, prepare 214ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index cfc76a0ac..a4f171172 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -219,6 +219,7 @@ export async function runVerifiedNativeExtensionSandbox( }); return await runHostedExtensionSandbox({ artifactDirectory: options.artifact.directory, + artifactDigest: options.artifact.digest, entry: options.manifest.entry, surfaceVersion: versions.surface, identity, diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index b947f782e..5266465f9 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -11,12 +11,14 @@ import { exchangeHostedExtension, type HostedExtensionProtocolResult, } from './hosted-extension-protocol.js'; +import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const DEFAULT_TIMEOUT_MS = 10_000; export interface RunHostedExtensionSandboxOptions { readonly artifactDirectory: string; + readonly artifactDigest: string; readonly entry: string; readonly surfaceVersion: string; readonly identity: { readonly provider: string; readonly event: string; readonly action?: string }; @@ -49,12 +51,24 @@ export async function runHostedExtensionSandbox( const runner = join(runtimeDirectory, 'runner.mjs'); const surfaceFacade = join(runtimeDirectory, 'surface'); try { + const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); + const snapshot = await materializePlugin( + runtimeDirectory, + 'hosted-extension', + storedFiles.filter(file => file.path !== 'manifest.json'), + ); + if (snapshot.digest !== options.artifactDigest) { + throw new PluginError( + 'plugin_source_drift', + 'Hosted extension changed while its isolated snapshot was created.', + ); + } await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); await writeSurfaceFacade(surfaceFacade); const args = sandboxArguments({ node, runner, - extension: realpathSync(options.artifactDirectory), + extension: realpathSync(snapshot.directory), surfaceFacade, surfaceRoot, }); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index f3db98c6a..eb2f86339 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -155,6 +155,41 @@ describe('hosted extension capability isolation', () => { } }]); }); + it('mounts the verified snapshot when the live store is atomically replaced before bwrap', async () => { + const installed = await artifact(); + const wrapperRoot = mkdtempSync(join(tmpdir(), 'hosted-bwrap-wrapper-')); + roots.push(wrapperRoot); + const wrapper = join(wrapperRoot, 'bwrap-wrapper'); + const replaced = `${installed.directory}.replaced`; + const replacementSource = hostileImport([{ type: 'error', message: 'replacement executed' }]); + writeFileSync(wrapper, `#!${process.execPath} +const { mkdirSync, renameSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); +renameSync(${JSON.stringify(installed.directory)}, ${JSON.stringify(replaced)}); +mkdirSync(${JSON.stringify(installed.directory)}, { recursive: true }); +writeFileSync(join(${JSON.stringify(installed.directory)}, 'babysitter.flow.ts'), ${JSON.stringify(replacementSource)}); +const child = spawnSync('/usr/bin/bwrap', process.argv.slice(2), { stdio: [0, 1, 2, 3] }); +if (child.error) throw child.error; +process.exit(child.status ?? 1); +`); + chmodSync(wrapper, 0o700); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + bubblewrapPath: wrapper, timeoutMs: 3_000, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toBe(1); + expect(readFileSync(join(installed.directory, 'babysitter.flow.ts'), 'utf8')).toBe(replacementSource); + }); + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { const source = ` import { flow, github } from '@relayflows/surface'; From 238af2b6a56924237ed6736ae33e39a3180bcc50 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 06:30:50 -0700 Subject: [PATCH 07/80] fix(sdk): bind hosted runtime generation Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 20 +- docs/CLOUD.md | 9 +- evidence/babysitter-gate8-isolation/README.md | 7 +- .../verification.txt | 19 +- packages/sdk/src/authored-flow-loader.ts | 18 +- .../sdk/src/hosted-extension-isolation.ts | 137 ++-------- packages/sdk/src/hosted-extension-runtime.ts | 237 ++++++++++++++++++ packages/sdk/src/index.ts | 4 +- .../tests/babysitter-native-extension.test.ts | 65 ++++- .../tests/hosted-extension-isolation.test.ts | 159 ------------ .../tests/hosted-extension-protocol.test.ts | 195 ++++++++++++++ 11 files changed, 561 insertions(+), 309 deletions(-) create mode 100644 packages/sdk/src/hosted-extension-runtime.ts create mode 100644 packages/sdk/tests/hosted-extension-protocol.test.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 812b6a5cf..f183f110d 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -31,11 +31,11 @@ commit pinned below. The sandbox contract is deliberately narrower than #442. It re-verifies the complete lock-backed installation and every manifest, binds it to the actual -base returned by `loadHostedExtensionBase` (which internally calls -`loadAuthoredFlow(..., { extensions: 'none' })`), detects +base returned in the same `loadHostedExtensionRuntime` generation (which +internally calls `loadAuthoredFlow(..., { extensions: 'none' })`), detects cross-extension route ambiguity, accepts only the exact published Babysitter -ref/digest/manifest and native permission profile, -permission profile, imports the entry only inside Linux bubblewrap plus Node's +ref/digest/manifest and native permission profile, imports the entry only +inside Linux bubblewrap plus Node's permission model, mounts a minimal trusted Surface facade (`flow`, `github`, and `getFlowDefinition`) instead of the general helper runtime, checks normalized input against non-serializable verified dispatch authority, and @@ -56,13 +56,13 @@ the branded dispatch, and waits for the adapter's authoritative outcome before settling any premature child terminal frame. An authoritative adapter rejection settles immediately with its original typed error even if the child hangs. -Before replacing #549's refusal, the hosted caller must obtain opaque base and -installation authorities for the same canonical flow path with -`loadHostedExtensionBase` and `loadHostedExtensionArtifacts`, then call -`runHostedCapabilityExtension` with both values; +Before replacing #549's refusal, the hosted caller must obtain an opaque base +and installation as one generation with `loadHostedExtensionRuntime`, then call +`runHostedCapabilityExtension` with both values. Every dispatch rechecks the +current extension declarations and imported base graph against that generation; using the ordinary compose loader would import extension top-level JavaScript -in the host before the sandbox exists. Cross-project pairing and structural -lookalikes fail before import. The selected store bytes are copied into a +in the host before the sandbox exists. Cross-project, cross-redeploy, stale, +and structural pairings fail before import. The selected store bytes are copied into a private snapshot whose digest is recomputed before bubblewrap mounts it, so a later live-store replacement cannot alter imported code. Independent review must prove this path at the exact release head. Broader per-agent-step diff --git a/docs/CLOUD.md b/docs/CLOUD.md index f1638c75f..7cd8cb1b0 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -391,10 +391,11 @@ fail closed. This release also refuses a matching handler with ordinary JavaScript and their manifest permissions are not yet isolated by the runtime (gate 8 / #442). The SDK now contains a Linux-only, capability-only isolation primitive for the native Babysitter profile. Hosted callers must load -the base with `loadHostedExtensionBase` (which internally sets -`extensions: 'none'`), resolve extension bytes with -`loadHostedExtensionArtifacts` (which does not import them), then pass both -opaque loader results to `runHostedCapabilityExtension`. The runner verifies +the base and complete installation together with +`loadHostedExtensionRuntime` (which internally sets `extensions: 'none'` and +does not import extension JavaScript), then pass its two opaque, +same-generation results to `runHostedCapabilityExtension`. Every dispatch +rechecks that generation before the runner verifies the complete lock-backed set, actual base compatibility, route uniqueness, and the exact reviewed Babysitter ref/digest/manifest before the artifact is imported inside a bubblewrap mount/PID/network/user namespace with an empty credential diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index b86fc27b1..08449b3ea 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -7,9 +7,10 @@ deployment. The enforced boundary is: -- load and retain the actual base flow with extensions disabled and resolve an - opaque complete installation for the same canonical flow path without - importing extension JavaScript; cross-project authority pairing refuses; +- load the actual base flow with extensions disabled and resolve the complete + installation as one opaque generation without importing extension + JavaScript; cross-project and same-path cross-redeploy pairing refuse, and + every dispatch rechecks the current declarations plus imported base graph; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index d315a90bb..e180b968a 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -6,7 +6,7 @@ $ /usr/bin/bwrap --version && node --version && git diff --check bubblewrap 0.12.0 v26.8.1 -$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts > @relayflows/sdk@2.0.26 typecheck > tsc --noEmit && tsc -p tsconfig.type-tests.json @@ -19,15 +19,16 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/preflight.test.ts (59 tests) 54ms - ✓ tests/babysitter-native-extension.test.ts (35 tests) 246ms - ✓ tests/authored-flow.test.ts (34 tests) 728ms - ✓ tests/hosted-extension-isolation.test.ts (21 tests) 1120ms + ✓ tests/preflight.test.ts (59 tests) 82ms + ✓ tests/babysitter-native-extension.test.ts (36 tests) 271ms + ✓ tests/authored-flow.test.ts (34 tests) 753ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 611ms + ✓ tests/hosted-extension-isolation.test.ts (12 tests) 634ms - Test Files 4 passed (4) - Tests 149 passed (149) - Start at 06:06:51 - Duration 2.15s (transform 808ms, setup 0ms, collect 3.01s, tests 2.15s, environment 1ms, prepare 214ms) + Test Files 5 passed (5) + Tests 151 passed (151) + Start at 06:29:13 + Duration 1.70s (transform 995ms, setup 0ms, collect 3.95s, tests 2.35s, environment 1ms, prepare 242ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/authored-flow-loader.ts b/packages/sdk/src/authored-flow-loader.ts index d17be8cae..dfd51e196 100644 --- a/packages/sdk/src/authored-flow-loader.ts +++ b/packages/sdk/src/authored-flow-loader.ts @@ -60,6 +60,8 @@ export interface LoadAuthoredFlowOptions { /** `compose` (default) verifies and appends the project's flow extensions; `none` loads the root alone. */ readonly extensions?: 'compose' | 'none'; readonly versions?: RuntimeVersions; + /** Internal cache-buster for a loader generation that must re-import source. */ + readonly importNonce?: string; } export interface LoadedAuthoredFlowNode { @@ -118,7 +120,10 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp if (cached !== undefined) return cached; visiting.add(absolutePath); try { - const { handle, getDefinition, surfaceAuthority } = await importAuthoredFlow(absolutePath); + const { handle, getDefinition, surfaceAuthority } = await importAuthoredFlow( + absolutePath, + options.importNonce, + ); const dependencies: string[] = []; for (const entry of getDefinition(handle).header.use ?? []) { // Validate again at the SDK boundary: the author's surface package may @@ -156,7 +161,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp // handle keeps the surface copy's own answer. const baseDefinition = root.getDefinition(root.handle); const extensions = await loadFlowExtensions(root.path, { definition: baseDefinition, surfaceAuthority: root.surfaceAuthority }, { - importFlow: importAuthoredFlow, + importFlow: path => importAuthoredFlow(path, options.importNonce), sameAuthority: (a, b) => canonicalize(a) === canonicalize(b), ...(options.versions === undefined ? {} : { versions: options.versions }), }); @@ -177,7 +182,10 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp surfaceAuthority: root.surfaceAuthority, graph: Object.freeze(graph), extensions }); } -async function importAuthoredFlow(path: string): Promise> { +async function importAuthoredFlow( + path: string, + importNonce?: string, +): Promise> { const absolutePath = resolve(path); try { accessSync(absolutePath, constants.R_OK); @@ -187,7 +195,9 @@ async function importAuthoredFlow(path: string): Promise; try { - authoredModule = await import(/* @vite-ignore */ pathToFileURL(absolutePath).href) as Record; + const moduleUrl = pathToFileURL(absolutePath); + if (importNonce !== undefined) moduleUrl.searchParams.set('relayflows_load', importNonce); + authoredModule = await import(/* @vite-ignore */ moduleUrl.href) as Record; } catch (error) { throw new AuthoredFlowLoadError( `Flow "${path}" could not be imported: ${errorMessage(error)}`, diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index a4f171172..84aa18207 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,35 +1,44 @@ import { createHash } from 'node:crypto'; -import { readFile, realpath } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { canonicalize } from './canonical.js'; -import { - hostedExtensionBaseFromLoadedFlow, - loadAuthoredFlow, -} from './authored-flow-loader.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; import { hostedExtensionDispatchIdentity, type HostedExtensionDispatch, } from './flow-extension-loader.js'; +import { + assertHostedInstallationAuthority, + assertHostedRuntimeAuthority, + type HostedExtensionArtifact, + type HostedExtensionBase, + type HostedExtensionInstallation, +} from './hosted-extension-runtime.js'; import { PluginError } from './plugin-manifest.js'; -import { findPluginProject } from './plugin-loader.js'; -import { reconcileDeclaredExtensions } from './plugin-lock.js'; -import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; +import { verifyStoredPlugin } from './plugin-store.js'; import { boundedJsonSnapshot, type HostedExtensionProtocolResult, } from './hosted-extension-protocol.js'; import { runHostedExtensionSandbox } from './hosted-extension-sandbox.js'; +export { + loadHostedExtensionArtifacts, + loadHostedExtensionBase, + loadHostedExtensionRuntime, +} from './hosted-extension-runtime.js'; +export type { + HostedExtensionArtifact, + HostedExtensionBase, + HostedExtensionInstallation, + HostedExtensionRuntime, +} from './hosted-extension-runtime.js'; + const HOSTED_WRITE = 'cloud:babysitter-turn'; const BABYSITTER_REF = 'github:AgentWorkforce/flows@d3ee3b55ae636518dd4ad562aca5bae9c99f1a05#extensions/babysitter'; const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; -const HOSTED_INSTALLATION_AUTHORITY = new WeakSet(); -const HOSTED_BASE_AUTHORITY = new WeakSet(); -const HOSTED_INSTALLATION_ORIGIN = new WeakMap(); -const HOSTED_BASE_ORIGIN = new WeakMap(); const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,200}$/; const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; @@ -42,24 +51,6 @@ const NATIVE_TRIGGERS = [ { provider: 'github', event: 'issue_comment', actions: ['created'] }, ]; -export interface HostedExtensionArtifact { - readonly ref: string; - readonly name: string; - readonly version: string; - readonly directory: string; - readonly digest: string; - readonly manifestSha256: string; -} - -export interface HostedExtensionInstallation { - readonly artifacts: readonly HostedExtensionArtifact[]; -} - -export interface HostedExtensionBase { - readonly name: string; - readonly version?: string; -} - export interface HostedCapabilityAuthority { readonly dispatch: HostedExtensionDispatch; readonly extension: Readonly<{ @@ -75,9 +66,9 @@ export interface HostedBabysitterCapability { } export interface RunHostedExtensionOptions { - /** Complete, lock-ordered set returned by loadHostedExtensionArtifacts. */ + /** Complete, lock-ordered set returned by loadHostedExtensionRuntime. */ readonly installation: HostedExtensionInstallation; - /** Identity read from the base loaded with extensions disabled. */ + /** Same-generation base returned by loadHostedExtensionRuntime. */ readonly base: HostedExtensionBase; readonly dispatch: HostedExtensionDispatch; /** Host-normalized delivery descriptor. Its event identity must equal dispatch. */ @@ -92,58 +83,6 @@ export interface RunHostedExtensionOptions { export type HostedExtensionResult = HostedExtensionProtocolResult; -/** - * Resolve installed extension artifacts without importing their JavaScript. - * Hosted callers pair this with `loadAuthoredFlow(..., { extensions: 'none' })`; - * using the ordinary compose loader would execute extension top-level code in - * the host before the sandbox exists. - */ -export async function loadHostedExtensionArtifacts( - flowPath: string, -): Promise { - const origin = await realpath(resolve(flowPath)); - const root = findPluginProject(dirname(origin)); - if (root === undefined) return installation([], origin); - const artifacts: HostedExtensionArtifact[] = []; - for (const { ref, entry } of reconcileDeclaredExtensions(root)) { - const directory = pluginStoreDirectory(root, entry.name, entry.digest); - await verifyStoredPlugin(directory, entry.digest); - artifacts.push(Object.freeze({ - ref, - name: entry.name, - version: entry.version, - directory, - digest: entry.digest, - manifestSha256: entry.manifestSha256, - })); - } - return installation(artifacts, origin); -} - -/** Load and brand the actual base with extension importing explicitly disabled. */ -export async function loadHostedExtensionBase(flowPath: string): Promise { - const origin = await realpath(resolve(flowPath)); - const loaded = await loadAuthoredFlow(origin, { extensions: 'none' }); - const identity = hostedExtensionBaseFromLoadedFlow(loaded); - const value = Object.freeze({ - name: identity.name, - ...(identity.version === undefined ? {} : { version: identity.version }), - }); - HOSTED_BASE_AUTHORITY.add(value); - HOSTED_BASE_ORIGIN.set(value, origin); - return value; -} - -function installation( - artifacts: readonly HostedExtensionArtifact[], - origin: string, -): HostedExtensionInstallation { - const value = Object.freeze({ artifacts: Object.freeze([...artifacts]) }); - HOSTED_INSTALLATION_AUTHORITY.add(value); - HOSTED_INSTALLATION_ORIGIN.set(value, origin); - return value; -} - /** * Execute a capability-only hosted extension in a Linux mount/PID/network/user * namespace. The extension is first imported inside that namespace. It sees @@ -159,26 +98,7 @@ export async function runHostedCapabilityExtension( options: RunHostedExtensionOptions, ): Promise { const identity = hostedExtensionDispatchIdentity(options.dispatch); - if (typeof options.base !== 'object' || options.base === null - || !HOSTED_BASE_AUTHORITY.has(options.base)) { - throw new PluginError( - 'plugin_incompatible', - 'Hosted extension base authority is malformed; use loadHostedExtensionBase.', - ); - } - if (typeof options.installation !== 'object' || options.installation === null - || !HOSTED_INSTALLATION_AUTHORITY.has(options.installation)) { - throw new PluginError( - 'plugin_source_invalid', - 'Hosted extension installation authority is malformed; use loadHostedExtensionArtifacts.', - ); - } - if (HOSTED_BASE_ORIGIN.get(options.base) !== HOSTED_INSTALLATION_ORIGIN.get(options.installation)) { - throw new PluginError( - 'plugin_source_invalid', - 'Hosted extension base and installation must originate from the same flow.', - ); - } + await assertHostedRuntimeAuthority(options.installation, options.base); const { artifact, manifest } = await selectHostedExtensionForRuntime( options.installation, options.base, @@ -241,10 +161,7 @@ export async function selectHostedExtensionForRuntime( identity: { readonly provider: string; readonly event: string; readonly action?: string }, versions: Readonly<{ sdk: string; surface: string }>, ): Promise<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> { - if (typeof value !== 'object' || value === null - || !HOSTED_INSTALLATION_AUTHORITY.has(value) || !Array.isArray(value.artifacts)) { - throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); - } + assertHostedInstallationAuthority(value); if (typeof base !== 'object' || base === null || typeof base.name !== 'string' || (base.version !== undefined && typeof base.version !== 'string')) { throw new PluginError('plugin_incompatible', 'Hosted base identity is malformed.'); @@ -448,6 +365,6 @@ function optionalRecord( return object; } -function sha256(value: Uint8Array): string { +function sha256(value: Uint8Array | string): string { return createHash('sha256').update(value).digest('hex'); } diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts new file mode 100644 index 000000000..8affc98bf --- /dev/null +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -0,0 +1,237 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { + hostedExtensionBaseFromLoadedFlow, + loadAuthoredFlow, +} from './authored-flow-loader.js'; +import { canonicalize } from './canonical.js'; +import { PluginError } from './plugin-manifest.js'; +import { findPluginProject } from './plugin-loader.js'; +import { reconcileDeclaredExtensions } from './plugin-lock.js'; +import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; + +const INSTALLATION_AUTHORITY = new WeakSet(); +const BASE_AUTHORITY = new WeakSet(); + +interface RuntimeGeneration { + readonly origin: string; + declarations: string; + graphPaths: readonly string[]; + graphSha256: string; + readonly importNonce: string; +} + +const INSTALLATION_GENERATION = new WeakMap(); +const BASE_GENERATION = new WeakMap(); + +export interface HostedExtensionArtifact { + readonly ref: string; + readonly name: string; + readonly version: string; + readonly directory: string; + readonly digest: string; + readonly manifestSha256: string; +} + +export interface HostedExtensionInstallation { + readonly artifacts: readonly HostedExtensionArtifact[]; +} + +export interface HostedExtensionBase { + readonly name: string; + readonly version?: string; +} + +export interface HostedExtensionRuntime { + readonly installation: HostedExtensionInstallation; + readonly base: HostedExtensionBase; +} + +/** Load one base/installation generation that cannot be paired across redeploys. */ +export async function loadHostedExtensionRuntime(flowPath: string): Promise { + const origin = await realpath(resolve(flowPath)); + const generation = newGeneration(origin); + const loaded = await installationAt(origin, generation); + generation.declarations = loaded.declarations; + const base = await baseAt(origin, generation); + if (loaded.declarations !== declaredExtensions(origin).signature) { + throw new PluginError( + 'plugin_source_drift', + 'Hosted extension declarations changed while their runtime generation was loaded.', + ); + } + Object.freeze(generation); + return Object.freeze({ installation: loaded.installation, base }); +} + +/** @internal Metadata-only test seam; public hosted callers use the combined loader. */ +export async function loadHostedExtensionArtifacts( + flowPath: string, +): Promise { + const origin = await realpath(resolve(flowPath)); + const generation = newGeneration(origin); + const loaded = await installationAt(origin, generation); + generation.declarations = loaded.declarations; + return loaded.installation; +} + +/** @internal Base-only test seam; public hosted callers use the combined loader. */ +export async function loadHostedExtensionBase(flowPath: string): Promise { + const origin = await realpath(resolve(flowPath)); + const generation = newGeneration(origin); + generation.declarations = declaredExtensions(origin).signature; + return await baseAt(origin, generation); +} + +/** Validate both opaque values and refuse a cached generation after redeploy. */ +export async function assertHostedRuntimeAuthority( + installation: HostedExtensionInstallation, + base: HostedExtensionBase, +): Promise { + if (typeof base !== 'object' || base === null || !BASE_AUTHORITY.has(base)) { + throw new PluginError( + 'plugin_incompatible', + 'Hosted extension base authority is malformed; use loadHostedExtensionRuntime.', + ); + } + if (typeof installation !== 'object' || installation === null + || !INSTALLATION_AUTHORITY.has(installation)) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension installation authority is malformed; use loadHostedExtensionRuntime.', + ); + } + const generation = BASE_GENERATION.get(base); + if (generation === undefined || generation !== INSTALLATION_GENERATION.get(installation)) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension base and installation must originate from the same runtime generation.', + ); + } + await assertCurrentGeneration(generation); +} + +/** @internal Validate a metadata-only installation used by selection tests. */ +export function assertHostedInstallationAuthority(value: unknown): asserts value is HostedExtensionInstallation { + if (typeof value !== 'object' || value === null || !INSTALLATION_AUTHORITY.has(value) + || !Array.isArray((value as Partial).artifacts)) { + throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); + } +} + +async function installationAt( + origin: string, + generation: RuntimeGeneration, +): Promise<{ installation: HostedExtensionInstallation; declarations: string }> { + const root = findPluginProject(dirname(origin)); + if (root === undefined) return { + installation: installation([], generation), declarations: canonicalize([]), + }; + const artifacts: HostedExtensionArtifact[] = []; + const declared = reconcileDeclaredExtensions(root); + for (const { ref, entry } of declared) { + const directory = pluginStoreDirectory(root, entry.name, entry.digest); + await verifyStoredPlugin(directory, entry.digest); + artifacts.push(Object.freeze({ + ref, + name: entry.name, + version: entry.version, + directory, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + })); + } + return { + installation: installation(artifacts, generation), + declarations: declarationSignature(declared), + }; +} + +function declaredExtensions(origin: string): { readonly signature: string } { + const root = findPluginProject(dirname(origin)); + return { + signature: root === undefined + ? canonicalize([]) + : declarationSignature(reconcileDeclaredExtensions(root)), + }; +} + +function declarationSignature( + declared: ReturnType, +): string { + return canonicalize(declared.map(({ ref, entry }) => ({ + ref, + name: entry.name, + version: entry.version, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + }))); +} + +function newGeneration(origin: string): RuntimeGeneration { + return { + origin, + declarations: canonicalize([]), + graphPaths: Object.freeze([]), + graphSha256: '', + importNonce: randomUUID(), + }; +} + +async function graphSha256(paths: readonly string[]): Promise { + const files = await Promise.all(paths.map(async path => ({ + path, + sha256: sha256(await readFile(path)), + }))); + return sha256(canonicalize(files)); +} + +async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { + let currentGraph: string; + try { currentGraph = await graphSha256(generation.graphPaths); } + catch { + throw new PluginError('plugin_source_invalid', 'Hosted extension runtime generation is no longer readable.'); + } + if (declaredExtensions(generation.origin).signature !== generation.declarations + || currentGraph !== generation.graphSha256) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension runtime generation is stale; reload the base and installation together.', + ); + } +} + +async function baseAt( + origin: string, + generation: RuntimeGeneration, +): Promise { + const loaded = await loadAuthoredFlow(origin, { + extensions: 'none', + importNonce: generation.importNonce, + }); + generation.graphPaths = Object.freeze(loaded.graph.map(node => node.path)); + generation.graphSha256 = await graphSha256(generation.graphPaths); + const identity = hostedExtensionBaseFromLoadedFlow(loaded); + const value = Object.freeze({ + name: identity.name, + ...(identity.version === undefined ? {} : { version: identity.version }), + }); + BASE_AUTHORITY.add(value); + BASE_GENERATION.set(value, generation); + return value; +} + +function installation( + artifacts: readonly HostedExtensionArtifact[], + generation: RuntimeGeneration, +): HostedExtensionInstallation { + const value = Object.freeze({ artifacts: Object.freeze([...artifacts]) }); + INSTALLATION_AUTHORITY.add(value); + INSTALLATION_GENERATION.set(value, generation); + return value; +} + +function sha256(value: Uint8Array | string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index c36a47af3..ccba51a44 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -60,11 +60,11 @@ export { SPEC_SCHEMA_VERSION } from './spec.js'; export { runHostedCapabilityExtension, - loadHostedExtensionArtifacts, - loadHostedExtensionBase, + loadHostedExtensionRuntime, type HostedExtensionArtifact, type HostedExtensionInstallation, type HostedExtensionBase, + type HostedExtensionRuntime, type HostedCapabilityAuthority, type HostedBabysitterCapability, type RunHostedExtensionOptions, diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 4176a3d28..8107aee68 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -11,7 +11,7 @@ import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extensi import { resolveExtensionSubmission } from '../src/flow-extension-submit.js'; import { loadHostedExtensionArtifacts, - loadHostedExtensionBase, + loadHostedExtensionRuntime, runHostedCapabilityExtension, selectHostedExtensionForRuntime, } from '../src/hosted-extension-isolation.js'; @@ -59,12 +59,12 @@ async function composed() { const flowPath = join(cwd, 'software-factory.flow.ts'); const baseLoaded = await loadAuthoredFlow(flowPath, { extensions: 'none', versions }); const baseDefinition = baseLoaded.getDefinition(baseLoaded.handle); - const hostedBase = await loadHostedExtensionBase(flowPath); + const hostedRuntime = await loadHostedExtensionRuntime(flowPath); return { cwd, flowPath, loaded, - hostedBase, + hostedRuntime, extension: loaded.extensions[0]!, base: { name: baseDefinition.name, version: baseDefinition.header.version }, }; @@ -126,7 +126,7 @@ describe('native Babysitter extension', () => { it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( 'runs the exact published 2.0.26 native bytes in the isolated capability path', async () => { - const installation = await loadHostedExtensionArtifacts(installed.flowPath); + const { installation, base } = installed.hostedRuntime; expect(installation.artifacts).toHaveLength(1); expect(installation.artifacts[0]).toMatchObject({ ref: REF, digest: DIGEST, manifestSha256: MANIFEST_SHA256 }); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ @@ -135,7 +135,7 @@ describe('native Babysitter extension', () => { const calls: unknown[] = []; await expect(runHostedCapabilityExtension({ installation, - base: installed.hostedBase, + base, dispatch, input: descriptor('pull_request.labeled'), babysitterTurn: { queue: async (request, authority) => { @@ -154,7 +154,7 @@ describe('native Babysitter extension', () => { }); it('refuses the real bytes on 2.0.25 before execution and admits them on 2.0.26', async () => { - const installation = await loadHostedExtensionArtifacts(installed.flowPath); + const installation = installed.hostedRuntime.installation; await expect(selectHostedExtensionForRuntime( installation, installed.base, { provider: 'github', event: 'pull_request', action: 'labeled' }, @@ -199,7 +199,7 @@ describe('native Babysitter extension', () => { await expect(runHostedCapabilityExtension({ installation: { artifacts: installation.artifacts } as never, - base: installed.hostedBase, + base: installed.hostedRuntime.base, dispatch, input: descriptor('pull_request.labeled'), babysitterTurn: { queue: async () => { @@ -212,7 +212,7 @@ describe('native Babysitter extension', () => { const otherProject = await composed(); await expect(runHostedCapabilityExtension({ installation, - base: otherProject.hostedBase, + base: otherProject.hostedRuntime.base, dispatch, input: descriptor('pull_request.labeled'), babysitterTurn: { queue: async () => { @@ -223,6 +223,55 @@ describe('native Babysitter extension', () => { expect(calls).toBe(0); }); + it('refuses stale installation authority after the same flow path is redeployed', async () => { + const configPath = join(installed.cwd, 'flows.json'); + const lockPath = join(installed.cwd, 'flows.lock.json'); + const originalConfig = readFileSync(configPath, 'utf8'); + const originalLock = readFileSync(lockPath, 'utf8'); + const originalFlow = readFileSync(installed.flowPath, 'utf8'); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + let calls = 0; + const capability = { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }; + try { + const config = JSON.parse(originalConfig) as { plugins?: string[] }; + config.plugins = []; + const lock = JSON.parse(originalLock) as { version: 2; plugins: unknown[] }; + lock.plugins = []; + writeFileSync(configPath, JSON.stringify(config)); + writeFileSync(lockPath, JSON.stringify(lock)); + writeFileSync(installed.flowPath, ` + import { flow } from '@relayflows/surface'; + export default flow('software-factory-redeployed', async f => f.done('success')); + `); + const redeployed = await loadHostedExtensionRuntime(installed.flowPath); + expect(redeployed.base.name).toBe('software-factory-redeployed'); + await expect(runHostedCapabilityExtension({ + installation: installed.hostedRuntime.installation, + base: redeployed.base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + await expect(runHostedCapabilityExtension({ + installation: installed.hostedRuntime.installation, + base: installed.hostedRuntime.base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + writeFileSync(configPath, originalConfig); + writeFileSync(lockPath, originalLock); + writeFileSync(installed.flowPath, originalFlow); + } + expect(calls).toBe(0); + }); + it('refuses a second installed extension that overlaps an action-specific route', async () => { const overlapRef = `github:AgentWorkforce/other@${'b'.repeat(40)}#extensions/overlap`; const overlapManifest = { diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index eb2f86339..d456b9a02 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -79,17 +79,6 @@ async function artifact(source = ordinaryExtension, value = manifest()): Promise }; } -function capabilityFrame(overrides: Record = {}): Record { - return { - type: 'capability', id: 1, name: 'cloud:babysitter-turn', - request: { delivery: { - deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', - pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, - ...overrides, - } }, - }; -} - /** Emit raw parent-protocol frames during import, before any handler can run. */ function hostileImport(frames: readonly unknown[]): string { const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); @@ -364,154 +353,6 @@ export default flow('babysitter', async f => f.done('declined')) } }); - it('treats direct protocol output as hostile and rejects completion while the adapter is pending', async () => { - const source = hostileImport([ - capabilityFrame(), - { type: 'result', completionReason: 'success', capabilityCalls: 1 }, - ]); - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - let resolveAdapter!: (value: unknown) => void; - const adapter = new Promise(resolve => { resolveAdapter = resolve; }); - let directFrameCalls = 0; - setTimeout(() => resolveAdapter({ receiptId: 'settled-after-refusal', status: 'queued' }), 20); - await expect(runVerifiedNativeExtensionSandbox({ - artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), - timeoutMs: 1_000, - babysitterTurn: { queue: async () => { - directFrameCalls += 1; - return await adapter; - } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); - // Descriptor 3 is hostile transport, not extra authority: an exact direct - // frame can consume the already-granted call once, but cannot forge a - // second call or claim success before the host outcome settles. - expect(directFrameCalls).toBe(1); - - const nonObject = hostileImport([null]); - let invalidFrameCalls = 0; - await expect(runVerifiedNativeExtensionSandbox({ - artifact: await artifact(nonObject), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), - babysitterTurn: { queue: async () => { - invalidFrameCalls += 1; - return { receiptId: 'never', status: 'queued' }; - } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); - expect(invalidFrameCalls).toBe(0); - }); - - it.each([ - ['different PR', capabilityFrame({ pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 999 } })], - ['different delivery', capabilityFrame({ deliveryId: 'delivery-2' })], - ['different event', capabilityFrame({ eventType: 'pull_request.opened' })], - ])('rejects an import-time %s frame with zero adapter calls', async (_case, frame) => { - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - let calls = 0; - await expect(runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([frame, { type: 'error', message: 'forged terminal' }])), - manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, - babysitterTurn: { queue: async () => { - calls += 1; - return { receiptId: 'never', status: 'queued' }; - } }, - })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); - expect(calls).toBe(0); - }); - - it('rejects a forged result before any capability call', async () => { - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - let calls = 0; - await expect(runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([{ type: 'result', completionReason: 'success', capabilityCalls: 1 }])), - manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), - babysitterTurn: { queue: async () => { - calls += 1; - return { receiptId: 'never', status: 'queued' }; - } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); - expect(calls).toBe(0); - }); - - it('rejects two forged calls after the authoritative first outcome settles', async () => { - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - let calls = 0; - await expect(runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([capabilityFrame(), capabilityFrame()])), - manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, - babysitterTurn: { queue: async () => { - calls += 1; - await new Promise(resolve => setTimeout(resolve, 20)); - return { receiptId: 'settled', status: 'queued' }; - } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); - expect(calls).toBe(1); - }); - - it.each(['reject', 'resolve'] as const)( - 'waits for a pending adapter to %s after a forged child error', async outcome => { - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); - let settle!: (value: unknown) => void; - let reject!: (error: Error) => void; - const adapter = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise; }); - let markInvoked!: () => void; - const invoked = new Promise(resolve => { markInvoked = resolve; }); - let completed = false; - const run = runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([capabilityFrame(), { type: 'error', message: 'premature' }])), - manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 1_000, - babysitterTurn: { queue: async () => { - markInvoked(); - return await adapter; - } }, - }).finally(() => { completed = true; }); - const observed = run.then(() => undefined, error => error as Error); - await invoked; - await new Promise(resolve => setTimeout(resolve, 20)); - expect(completed).toBe(false); - if (outcome === 'reject') reject(refusal); - else settle({ receiptId: 'settled', status: 'queued' }); - const error = await observed; - if (outcome === 'reject') expect(error).toBe(refusal); - else expect(error).toMatchObject({ code: 'plugin_unsupported' }); - }, - ); - - it('returns a typed adapter rejection even when the hostile child hangs', async () => { - const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); - let rejectAdapter!: (error: Error) => void; - const adapter = new Promise((_resolve, reject) => { rejectAdapter = reject; }); - let markInvoked!: () => void; - const invoked = new Promise(resolve => { markInvoked = resolve; }); - let calls = 0; - const run = runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([capabilityFrame()])), - manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), timeoutMs: 100, - babysitterTurn: { queue: async () => { - calls += 1; - markInvoked(); - return await adapter; - } }, - }); - const observed = run.then(() => undefined, error => error as Error); - await invoked; - rejectAdapter(refusal); - expect(await observed).toBe(refusal); - expect(calls).toBe(1); - }); - it('accepts only Node releases that implement every sandbox flag', () => { expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts new file mode 100644 index 000000000..60c9060a3 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; +import { + runVerifiedNativeExtensionSandbox, + type HostedExtensionArtifact, +} from '../src/hosted-extension-isolation.js'; +import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; +import { materializePlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +const manifest = () => ({ + schema: 2, + kind: 'flow-extension', + name: 'babysitter', + version: '0.2.0', + compat: { surface: '*', sdk: '*', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'babysitter.flow.ts', + extends: { handlers: true, hooks: [] }, + triggers: [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, + ], + permissions: { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'], + budget: { dollars: 1, wallclock: '5m' }, + }, + preflight: { credentials: [], servers: [] }, +}); + +function descriptor() { + return { + event: { provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' }, + pullRequest: { owner: 'AgentWorkforce', repo: 'flows', number: 551 }, + }; +} + +async function artifact(source: string): Promise { + const root = mkdtempSync(join(tmpdir(), 'hosted-protocol-test-')); + roots.push(root); + const manifestBytes = Buffer.from(JSON.stringify(manifest())); + const stored = await materializePlugin(root, 'babysitter', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'babysitter.flow.ts', data: Buffer.from(source) }, + ]); + return { + ref: `github:AgentWorkforce/flows@${'a'.repeat(40)}#extensions/babysitter`, + name: 'babysitter', version: '0.2.0', directory: stored.directory, digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + }; +} + +function capabilityFrame(overrides: Record = {}): Record { + return { + type: 'capability', id: 1, name: 'cloud:babysitter-turn', + request: { delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + ...overrides, + } }, + }; +} + +function hostileImport(frames: readonly unknown[]): string { + const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); + return ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + ${writes} + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async () => { await new Promise(() => {}); }); + `; +} + +const dispatch = () => hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', +}); + +describe('hosted extension hostile protocol', () => { + it('rejects completion while an exact direct call is pending', async () => { + let settle!: (value: unknown) => void; + const adapter = new Promise(resolve => { settle = resolve; }); + let calls = 0; + setTimeout(() => settle({ receiptId: 'settled-after-refusal', status: 'queued' }), 20); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([ + capabilityFrame(), + { type: 'result', completionReason: 'success', capabilityCalls: 1 }, + ])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { calls += 1; return await adapter; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(1); + }); + + it('rejects non-object protocol output with zero adapter calls', async () => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([null])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it.each([ + ['different PR', capabilityFrame({ pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 999 } })], + ['different delivery', capabilityFrame({ deliveryId: 'delivery-2' })], + ['different event', capabilityFrame({ eventType: 'pull_request.opened' })], + ])('rejects an import-time %s frame with zero adapter calls', async (_case, frame) => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([frame, { type: 'error', message: 'forged terminal' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it('rejects a forged result before any capability call', async () => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([{ type: 'result', completionReason: 'success', capabilityCalls: 1 }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it('rejects two forged calls after the authoritative first outcome settles', async () => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { + calls += 1; + await new Promise(resolve => setTimeout(resolve, 20)); + return { receiptId: 'settled', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(1); + }); + + it.each(['reject', 'resolve'] as const)( + 'waits for a pending adapter to %s after a forged child error', async outcome => { + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let settle!: (value: unknown) => void; + let reject!: (error: Error) => void; + const adapter = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let completed = false; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), { type: 'error', message: 'premature' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { markInvoked(); return await adapter; } }, + }).finally(() => { completed = true; }); + const observed = run.then(() => undefined, error => error as Error); + await invoked; + await new Promise(resolve => setTimeout(resolve, 20)); + expect(completed).toBe(false); + if (outcome === 'reject') reject(refusal); + else settle({ receiptId: 'settled', status: 'queued' }); + const error = await observed; + if (outcome === 'reject') expect(error).toBe(refusal); + else expect(error).toMatchObject({ code: 'plugin_unsupported' }); + }, + ); + + it('returns a typed adapter rejection even when the hostile child hangs', async () => { + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let rejectAdapter!: (error: Error) => void; + const adapter = new Promise((_resolve, reject) => { rejectAdapter = reject; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let calls = 0; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 100, + babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, + }); + const observed = run.then(() => undefined, error => error as Error); + await invoked; + rejectAdapter(refusal); + expect(await observed).toBe(refusal); + expect(calls).toBe(1); + }); +}); From 5a645f1500ca7ddc91cb27e04eb9559d38c07583 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 06:48:34 -0700 Subject: [PATCH 08/80] fix(sdk): snapshot hosted base generation Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 5 +- docs/CLOUD.md | 4 +- evidence/babysitter-gate8-isolation/README.md | 4 +- .../verification.txt | 16 +-- packages/sdk/src/authored-flow-loader.ts | 18 +-- packages/sdk/src/hosted-base-snapshot.ts | 131 ++++++++++++++++++ packages/sdk/src/hosted-extension-runtime.ts | 67 ++++----- .../tests/babysitter-native-extension.test.ts | 29 +++- .../tests/hosted-extension-protocol.test.ts | 12 +- packages/sdk/tsconfig.tests.json | 1 + 10 files changed, 215 insertions(+), 72 deletions(-) create mode 100644 packages/sdk/src/hosted-base-snapshot.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index f183f110d..dc3aa6d62 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -59,7 +59,10 @@ settles immediately with its original typed error even if the child hangs. Before replacing #549's refusal, the hosted caller must obtain an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then call `runHostedCapabilityExtension` with both values. Every dispatch rechecks the -current extension declarations and imported base graph against that generation; +current extension declarations and complete project source tree against that +generation. The base and its ordinary relative imports load from a unique +private snapshot, so mutable paths and an earlier Node module cache cannot +change or substitute the identity that was admitted; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Cross-project, cross-redeploy, stale, and structural pairings fail before import. The selected store bytes are copied into a diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 7cd8cb1b0..a58ed2466 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -395,7 +395,9 @@ the base and complete installation together with `loadHostedExtensionRuntime` (which internally sets `extensions: 'none'` and does not import extension JavaScript), then pass its two opaque, same-generation results to `runHostedCapabilityExtension`. Every dispatch -rechecks that generation before the runner verifies +rechecks the current declarations and complete project source against that +generation. The base and ordinary relative imports execute from a unique +private snapshot before the runner verifies the complete lock-backed set, actual base compatibility, route uniqueness, and the exact reviewed Babysitter ref/digest/manifest before the artifact is imported inside a bubblewrap mount/PID/network/user namespace with an empty credential diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 08449b3ea..d695c1e22 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -10,7 +10,9 @@ The enforced boundary is: - load the actual base flow with extensions disabled and resolve the complete installation as one opaque generation without importing extension JavaScript; cross-project and same-path cross-redeploy pairing refuse, and - every dispatch rechecks the current declarations plus imported base graph; + every dispatch rechecks the current declarations plus complete project + source; the base and ordinary relative imports load from one unique private + snapshot, never mutable deployment paths or an earlier module cache; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index e180b968a..a24b53153 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,16 +19,16 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/preflight.test.ts (59 tests) 82ms - ✓ tests/babysitter-native-extension.test.ts (36 tests) 271ms - ✓ tests/authored-flow.test.ts (34 tests) 753ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 611ms - ✓ tests/hosted-extension-isolation.test.ts (12 tests) 634ms + ✓ tests/preflight.test.ts (59 tests) 92ms + ✓ tests/babysitter-native-extension.test.ts (37 tests) 408ms + ✓ tests/authored-flow.test.ts (34 tests) 710ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 632ms + ✓ tests/hosted-extension-isolation.test.ts (12 tests) 653ms Test Files 5 passed (5) - Tests 151 passed (151) - Start at 06:29:13 - Duration 1.70s (transform 995ms, setup 0ms, collect 3.95s, tests 2.35s, environment 1ms, prepare 242ms) + Tests 152 passed (152) + Start at 06:47:25 + Duration 1.71s (transform 911ms, setup 0ms, collect 3.88s, tests 2.49s, environment 1ms, prepare 260ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/authored-flow-loader.ts b/packages/sdk/src/authored-flow-loader.ts index dfd51e196..d17be8cae 100644 --- a/packages/sdk/src/authored-flow-loader.ts +++ b/packages/sdk/src/authored-flow-loader.ts @@ -60,8 +60,6 @@ export interface LoadAuthoredFlowOptions { /** `compose` (default) verifies and appends the project's flow extensions; `none` loads the root alone. */ readonly extensions?: 'compose' | 'none'; readonly versions?: RuntimeVersions; - /** Internal cache-buster for a loader generation that must re-import source. */ - readonly importNonce?: string; } export interface LoadedAuthoredFlowNode { @@ -120,10 +118,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp if (cached !== undefined) return cached; visiting.add(absolutePath); try { - const { handle, getDefinition, surfaceAuthority } = await importAuthoredFlow( - absolutePath, - options.importNonce, - ); + const { handle, getDefinition, surfaceAuthority } = await importAuthoredFlow(absolutePath); const dependencies: string[] = []; for (const entry of getDefinition(handle).header.use ?? []) { // Validate again at the SDK boundary: the author's surface package may @@ -161,7 +156,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp // handle keeps the surface copy's own answer. const baseDefinition = root.getDefinition(root.handle); const extensions = await loadFlowExtensions(root.path, { definition: baseDefinition, surfaceAuthority: root.surfaceAuthority }, { - importFlow: path => importAuthoredFlow(path, options.importNonce), + importFlow: importAuthoredFlow, sameAuthority: (a, b) => canonicalize(a) === canonicalize(b), ...(options.versions === undefined ? {} : { versions: options.versions }), }); @@ -182,10 +177,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp surfaceAuthority: root.surfaceAuthority, graph: Object.freeze(graph), extensions }); } -async function importAuthoredFlow( - path: string, - importNonce?: string, -): Promise> { +async function importAuthoredFlow(path: string): Promise> { const absolutePath = resolve(path); try { accessSync(absolutePath, constants.R_OK); @@ -195,9 +187,7 @@ async function importAuthoredFlow( let authoredModule: Record; try { - const moduleUrl = pathToFileURL(absolutePath); - if (importNonce !== undefined) moduleUrl.searchParams.set('relayflows_load', importNonce); - authoredModule = await import(/* @vite-ignore */ moduleUrl.href) as Record; + authoredModule = await import(/* @vite-ignore */ pathToFileURL(absolutePath).href) as Record; } catch (error) { throw new AuthoredFlowLoadError( `Flow "${path}" could not be imported: ${errorMessage(error)}`, diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts new file mode 100644 index 000000000..25ff4a012 --- /dev/null +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -0,0 +1,131 @@ +import { createHash } from 'node:crypto'; +import { + chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { canonicalize } from './canonical.js'; +import { findPluginProject } from './plugin-loader.js'; +import { PluginError } from './plugin-manifest.js'; + +const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); +const MAX_FILES = 10_000; +const MAX_BYTES = 64 * 1024 * 1024; + +interface SourceFile { + readonly path: string; + readonly bytes: Uint8Array; + readonly sha256: string; +} + +export interface HostedBaseSnapshot { + readonly liveRoot: string; + readonly liveDigest: string; + readonly snapshotRoot: string; + readonly snapshotFlowPath: string; +} + +/** + * Materialize the authored project source into a private, unique directory. + * The base and every ordinary relative import therefore execute from the exact + * buffered bytes represented by liveDigest, never a mutable deployment path or + * a module cached for an earlier deployment. + */ +export async function createHostedBaseSnapshot(flowPath: string): Promise { + const origin = await realpath(resolve(flowPath)); + const discovered = findPluginProject(dirname(origin)) ?? dirname(origin); + const liveRoot = await realpath(discovered); + const flowRelative = relative(liveRoot, origin); + if (flowRelative === '' || isAbsolute(flowRelative) + || flowRelative === '..' || flowRelative.startsWith(`..${sep}`)) { + throw invalid('Hosted base flow must be a file inside its project root.'); + } + const files = await readSourceTree(liveRoot); + const liveDigest = sourceDigest(files); + const snapshotRoot = await mkdtemp(join(tmpdir(), 'flows-hosted-base-')); + await chmod(snapshotRoot, 0o700); + try { + for (const file of files) { + const target = join(snapshotRoot, file.path); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + await writeFile(target, file.bytes, { flag: 'wx', mode: 0o400 }); + } + const nodeModules = join(liveRoot, 'node_modules'); + try { + if ((await lstat(nodeModules)).isDirectory()) { + await symlink(nodeModules, join(snapshotRoot, 'node_modules'), 'dir'); + } + } catch (error) { + if (!isMissing(error)) throw error; + } + if (sourceDigest(await readSourceTree(snapshotRoot)) !== liveDigest) { + throw invalid('Hosted base private snapshot does not match its buffered source.'); + } + return Object.freeze({ + liveRoot, + liveDigest, + snapshotRoot, + snapshotFlowPath: join(snapshotRoot, flowRelative), + }); + } catch (error) { + await rm(snapshotRoot, { recursive: true, force: true }); + throw error; + } +} + +export async function hostedBaseSourceDigest(root: string): Promise { + return sourceDigest(await readSourceTree(root)); +} + +export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Promise { + await rm(snapshot.snapshotRoot, { recursive: true, force: true }); +} + +async function readSourceTree(root: string): Promise { + const files: SourceFile[] = []; + let totalBytes = 0; + async function visit(directory: string, prefix: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; + const relativePath = prefix === '' ? entry.name : join(prefix, entry.name); + const absolutePath = join(directory, entry.name); + if (entry.isDirectory()) { + await visit(absolutePath, relativePath); + } else if (entry.isFile()) { + const bytes = await readFile(absolutePath); + totalBytes += bytes.byteLength; + if (files.length >= MAX_FILES || totalBytes > MAX_BYTES) { + throw invalid('Hosted base source tree exceeds the snapshot limit.'); + } + files.push(Object.freeze({ path: relativePath, bytes, sha256: sha256(bytes) })); + } else { + throw invalid(`Hosted base source tree contains unsupported entry "${relativePath}".`); + } + } + } + try { await visit(root, ''); } + catch (error) { + if (error instanceof PluginError) throw error; + throw invalid('Hosted base source tree is unreadable.'); + } + return Object.freeze(files); +} + +function sourceDigest(files: readonly SourceFile[]): string { + return sha256(canonicalize(files.map(file => ({ path: file.path, sha256: file.sha256 })))); +} + +function sha256(value: Uint8Array | string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalid(message: string): PluginError { + return new PluginError('plugin_source_invalid', message); +} + +function isMissing(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error + && (error as { code?: unknown }).code === 'ENOENT'; +} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 8affc98bf..548946b76 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,11 +1,15 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { readFile, realpath } from 'node:fs/promises'; +import { realpath } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { hostedExtensionBaseFromLoadedFlow, loadAuthoredFlow, } from './authored-flow-loader.js'; import { canonicalize } from './canonical.js'; +import { + createHostedBaseSnapshot, + hostedBaseSourceDigest, + removeHostedBaseSnapshot, +} from './hosted-base-snapshot.js'; import { PluginError } from './plugin-manifest.js'; import { findPluginProject } from './plugin-loader.js'; import { reconcileDeclaredExtensions } from './plugin-lock.js'; @@ -17,9 +21,8 @@ const BASE_AUTHORITY = new WeakSet(); interface RuntimeGeneration { readonly origin: string; declarations: string; - graphPaths: readonly string[]; - graphSha256: string; - readonly importNonce: string; + sourceRoot: string; + sourceSha256: string; } const INSTALLATION_GENERATION = new WeakMap(); @@ -61,6 +64,7 @@ export async function loadHostedExtensionRuntime(flowPath: string): Promise { - const files = await Promise.all(paths.map(async path => ({ - path, - sha256: sha256(await readFile(path)), - }))); - return sha256(canonicalize(files)); -} - async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { - let currentGraph: string; - try { currentGraph = await graphSha256(generation.graphPaths); } + let currentSource: string; + try { currentSource = await hostedBaseSourceDigest(generation.sourceRoot); } catch { throw new PluginError('plugin_source_invalid', 'Hosted extension runtime generation is no longer readable.'); } if (declaredExtensions(generation.origin).signature !== generation.declarations - || currentGraph !== generation.graphSha256) { + || currentSource !== generation.sourceSha256) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension runtime generation is stale; reload the base and installation together.', @@ -206,20 +201,22 @@ async function baseAt( origin: string, generation: RuntimeGeneration, ): Promise { - const loaded = await loadAuthoredFlow(origin, { - extensions: 'none', - importNonce: generation.importNonce, - }); - generation.graphPaths = Object.freeze(loaded.graph.map(node => node.path)); - generation.graphSha256 = await graphSha256(generation.graphPaths); - const identity = hostedExtensionBaseFromLoadedFlow(loaded); - const value = Object.freeze({ - name: identity.name, - ...(identity.version === undefined ? {} : { version: identity.version }), - }); - BASE_AUTHORITY.add(value); - BASE_GENERATION.set(value, generation); - return value; + const snapshot = await createHostedBaseSnapshot(origin); + generation.sourceRoot = snapshot.liveRoot; + generation.sourceSha256 = snapshot.liveDigest; + try { + const loaded = await loadAuthoredFlow(snapshot.snapshotFlowPath, { extensions: 'none' }); + const identity = hostedExtensionBaseFromLoadedFlow(loaded); + const value = Object.freeze({ + name: identity.name, + ...(identity.version === undefined ? {} : { version: identity.version }), + }); + BASE_AUTHORITY.add(value); + BASE_GENERATION.set(value, generation); + return value; + } finally { + await removeHostedBaseSnapshot(snapshot); + } } function installation( @@ -231,7 +228,3 @@ function installation( INSTALLATION_GENERATION.set(value, generation); return value; } - -function sha256(value: Uint8Array | string): string { - return createHash('sha256').update(value).digest('hex'); -} diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 8107aee68..15012c846 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -49,9 +49,11 @@ async function composed() { symlinkSync(resolve('node_modules/@relayflows/surface'), join(cwd, 'node_modules/@relayflows/surface')); writeFileSync(join(cwd, 'package.json'), '{"type":"module"}'); writeFileSync(join(cwd, 'flows.json'), JSON.stringify({ cli: 'codex', executors: ['github'] })); + writeFileSync(join(cwd, 'base-name.ts'), `export const baseName = 'software-factory';\n`); writeFileSync(join(cwd, 'software-factory.flow.ts'), ` import { flow } from '@relayflows/surface'; - export default flow('software-factory', { budget: { dollars: 10, wallclock: '1h' } }, async f => { f.done('success'); }); + import { baseName } from './base-name.ts'; + export default flow(baseName, { budget: { dollars: 10, wallclock: '1h' } }, async f => { f.done('success'); }); `); const io = { stdout: () => {}, stderr: (s: string) => { throw new Error(s); } }; expect(await addExtensionPlugin(REF, io, { cwd, fetch: github().fetch, now, versions })).toBe(0); @@ -226,9 +228,10 @@ describe('native Babysitter extension', () => { it('refuses stale installation authority after the same flow path is redeployed', async () => { const configPath = join(installed.cwd, 'flows.json'); const lockPath = join(installed.cwd, 'flows.lock.json'); + const helperPath = join(installed.cwd, 'base-name.ts'); const originalConfig = readFileSync(configPath, 'utf8'); const originalLock = readFileSync(lockPath, 'utf8'); - const originalFlow = readFileSync(installed.flowPath, 'utf8'); + const originalHelper = readFileSync(helperPath, 'utf8'); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', }); @@ -244,10 +247,7 @@ describe('native Babysitter extension', () => { lock.plugins = []; writeFileSync(configPath, JSON.stringify(config)); writeFileSync(lockPath, JSON.stringify(lock)); - writeFileSync(installed.flowPath, ` - import { flow } from '@relayflows/surface'; - export default flow('software-factory-redeployed', async f => f.done('success')); - `); + writeFileSync(helperPath, `export const baseName = 'software-factory-redeployed';\n`); const redeployed = await loadHostedExtensionRuntime(installed.flowPath); expect(redeployed.base.name).toBe('software-factory-redeployed'); await expect(runHostedCapabilityExtension({ @@ -267,11 +267,26 @@ describe('native Babysitter extension', () => { } finally { writeFileSync(configPath, originalConfig); writeFileSync(lockPath, originalLock); - writeFileSync(installed.flowPath, originalFlow); + writeFileSync(helperPath, originalHelper); } expect(calls).toBe(0); }); + it('refuses when live project bytes change while its private base snapshot imports', async () => { + const racing = await composed(); + const helperPath = join(racing.cwd, 'base-name.ts'); + writeFileSync(racing.flowPath, ` + import { writeFileSync } from 'node:fs'; + import { flow } from '@relayflows/surface'; + import { baseName } from './base-name.ts'; + writeFileSync(${JSON.stringify(helperPath)}, "export const baseName = 'release-manager';\\n"); + export default flow(baseName, async f => f.done('success')); + `); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(readFileSync(helperPath, 'utf8')).toContain('release-manager'); + }); + it('refuses a second installed extension that overlaps an action-specific route', async () => { const overlapRef = `github:AgentWorkforce/other@${'b'.repeat(40)}#extensions/overlap`; const overlapManifest = { diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 60c9060a3..c354bc94b 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -183,13 +183,19 @@ describe('hosted extension hostile protocol', () => { let calls = 0; const run = runVerifiedNativeExtensionSandbox({ artifact: await artifact(hostileImport([capabilityFrame()])), - manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 100, + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, }); const observed = run.then(() => undefined, error => error as Error); - await invoked; + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('hostile child did not invoke the adapter')), + 10_000, + ); + void invoked.then(() => { clearTimeout(timeout); resolve(); }, reject); + }); rejectAdapter(refusal); expect(await observed).toBe(refusal); expect(calls).toBe(1); - }); + }, 15_000); }); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index ed8f259dd..81575334f 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -27,6 +27,7 @@ "tests/authored-flow.test.ts", "tests/authored-agent-permissions.test.ts", "tests/hosted-extension-isolation.test.ts", + "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", "tests/journal-client-loopback.ts", From f7d2545f74c897a74a72a9cb19cd7178891b81a9 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 07:20:08 -0700 Subject: [PATCH 09/80] fix(sdk): attest hosted base dependencies Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 12 +- .../verification.txt | 24 ++- packages/sdk/src/hosted-base-snapshot.ts | 197 ++++++++++++++---- packages/sdk/src/hosted-extension-runtime.ts | 17 +- .../tests/babysitter-native-extension.test.ts | 24 ++- .../sdk/tests/hosted-base-snapshot.test.ts | 74 +++++++ packages/sdk/tsconfig.tests.json | 1 + 7 files changed, 277 insertions(+), 72 deletions(-) create mode 100644 packages/sdk/tests/hosted-base-snapshot.test.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index dc3aa6d62..28f46290f 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -31,8 +31,7 @@ commit pinned below. The sandbox contract is deliberately narrower than #442. It re-verifies the complete lock-backed installation and every manifest, binds it to the actual -base returned in the same `loadHostedExtensionRuntime` generation (which -internally calls `loadAuthoredFlow(..., { extensions: 'none' })`), detects +base returned in the same `loadHostedExtensionRuntime` generation, detects cross-extension route ambiguity, accepts only the exact published Babysitter ref/digest/manifest and native permission profile, imports the entry only inside Linux bubblewrap plus Node's @@ -60,9 +59,12 @@ Before replacing #549's refusal, the hosted caller must obtain an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then call `runHostedCapabilityExtension` with both values. Every dispatch rechecks the current extension declarations and complete project source tree against that -generation. The base and its ordinary relative imports load from a unique -private snapshot, so mutable paths and an earlier Node module cache cannot -change or substitute the identity that was admitted; +generation. The base, its ordinary relative imports, and the exact host-owned +Surface package load from one rehashed private snapshot under Node's read-only +permission model. Project `node_modules` is neither copied nor linked, and any +base import other than the attested Surface package or its own relative source +fails resolution. Mutable dependency paths and an earlier Node module cache +therefore cannot change or substitute the identity that was admitted; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Cross-project, cross-redeploy, stale, and structural pairings fail before import. The selected store bytes are copied into a diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index a24b53153..c2aee2963 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -6,7 +6,7 @@ $ /usr/bin/bwrap --version && node --version && git diff --check bubblewrap 0.12.0 v26.8.1 -$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-base-snapshot.test.ts tests/hosted-extension-isolation.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts > @relayflows/sdk@2.0.26 typecheck > tsc --noEmit && tsc -p tsconfig.type-tests.json @@ -19,16 +19,18 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/preflight.test.ts (59 tests) 92ms - ✓ tests/babysitter-native-extension.test.ts (37 tests) 408ms - ✓ tests/authored-flow.test.ts (34 tests) 710ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 632ms - ✓ tests/hosted-extension-isolation.test.ts (12 tests) 653ms - - Test Files 5 passed (5) - Tests 152 passed (152) - Start at 06:47:25 - Duration 1.71s (transform 911ms, setup 0ms, collect 3.88s, tests 2.49s, environment 1ms, prepare 260ms) + ✓ tests/hosted-base-snapshot.test.ts (2 tests) 121ms + ✓ tests/preflight.test.ts (59 tests) 63ms + ✓ tests/authored-flow.test.ts (34 tests) 737ms + ✓ tests/hosted-extension-isolation.test.ts (12 tests) 591ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 641ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 1279ms + ✓ native Babysitter extension > refuses project-owned package imports instead of following live node_modules 364ms + + Test Files 6 passed (6) + Tests 155 passed (155) + Start at 07:16:27 + Duration 2.25s (transform 1.05s, setup 0ms, collect 4.30s, tests 3.43s, environment 1ms, prepare 363ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 25ff4a012..c73f5bc3a 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,9 +1,11 @@ +import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { - chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile, -} from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; import { canonicalize } from './canonical.js'; import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; @@ -11,6 +13,8 @@ import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); const MAX_FILES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; +const execFileAsync = promisify(execFile); +const hostRequire = createRequire(import.meta.url); interface SourceFile { readonly path: string; @@ -18,29 +22,45 @@ interface SourceFile { readonly sha256: string; } +export interface HostedBaseSourceRoot { + readonly root: string; + readonly prefix: string; +} + export interface HostedBaseSnapshot { - readonly liveRoot: string; + readonly liveSources: readonly HostedBaseSourceRoot[]; readonly liveDigest: string; readonly snapshotRoot: string; readonly snapshotFlowPath: string; + readonly snapshotSurfaceDefinitionPath: string; +} + +export interface HostedBaseIdentity { + readonly name: string; + readonly version?: string; } /** - * Materialize the authored project source into a private, unique directory. - * The base and every ordinary relative import therefore execute from the exact - * buffered bytes represented by liveDigest, never a mutable deployment path or - * a module cached for an earlier deployment. + * Materialize project source plus the SDK-owned Surface package. Project + * node_modules is never linked or read by the imported base; any package other + * than the host's attested Surface fails resolution. Every executable file is + * represented in liveDigest and copied to the private identity directory. */ -export async function createHostedBaseSnapshot(flowPath: string): Promise { +export async function createHostedBaseSnapshot( + flowPath: string, + /** @internal Test seam; hosted callers cannot supply trust roots. */ + surfaceEntry = hostRequire.resolve('@relayflows/surface'), +): Promise { const origin = await realpath(resolve(flowPath)); const discovered = findPluginProject(dirname(origin)) ?? dirname(origin); - const liveRoot = await realpath(discovered); - const flowRelative = relative(liveRoot, origin); + const projectRoot = await realpath(discovered); + const flowRelative = relative(projectRoot, origin); if (flowRelative === '' || isAbsolute(flowRelative) || flowRelative === '..' || flowRelative.startsWith(`..${sep}`)) { throw invalid('Hosted base flow must be a file inside its project root.'); } - const files = await readSourceTree(liveRoot); + const liveSources = await trustedSourceRoots(projectRoot, surfaceEntry); + const files = await readAuthorityFiles(liveSources); const liveDigest = sourceDigest(files); const snapshotRoot = await mkdtemp(join(tmpdir(), 'flows-hosted-base-')); await chmod(snapshotRoot, 0o700); @@ -50,22 +70,17 @@ export async function createHostedBaseSnapshot(flowPath: string): Promise { - return sourceDigest(await readSourceTree(root)); +/** Import the private snapshot in a child that cannot read fallback packages. */ +export async function hostedBaseIdentityFromSnapshot( + snapshot: HostedBaseSnapshot, +): Promise { + const bootstrap = ` + const [flowUrl, runtimeUrl] = JSON.parse(process.argv[1]); + const authored = await import(flowUrl); + const runtime = await import(runtimeUrl); + const definition = runtime.getFlowDefinition(authored.default); + const identity = {name: definition.name}; + if (definition.header.version !== undefined) identity.version = definition.header.version; + process.stdout.write(JSON.stringify(identity)); + `; + let stdout: string; + try { + ({ stdout } = await execFileAsync(process.execPath, [ + '--permission', + `--allow-fs-read=${snapshot.snapshotRoot}`, + '--experimental-strip-types', + '--disable-warning=ExperimentalWarning', + '--input-type=module', + '--eval', bootstrap, + JSON.stringify([ + pathToFileURL(snapshot.snapshotFlowPath).href, + pathToFileURL(snapshot.snapshotSurfaceDefinitionPath).href, + ]), + ], { cwd: snapshot.snapshotRoot, env: {}, timeout: 10_000, maxBuffer: 4096 })); + } catch { + throw invalid('Hosted base could not be imported from its private dependency snapshot.'); + } + let value: unknown; + try { value = JSON.parse(stdout); } + catch { throw invalid('Hosted base identity response is malformed.'); } + if (typeof value !== 'object' || value === null || Array.isArray(value) + || typeof (value as { name?: unknown }).name !== 'string' + || ((value as { version?: unknown }).version !== undefined + && typeof (value as { version?: unknown }).version !== 'string') + || Object.keys(value).some(key => !['name', 'version'].includes(key))) { + throw invalid('Hosted base identity response is malformed.'); + } + const identity = value as { name: string; version?: string }; + return Object.freeze({ + name: identity.name, + ...(identity.version === undefined ? {} : { version: identity.version }), + }); +} + +export async function hostedBaseSourceDigest( + sources: readonly HostedBaseSourceRoot[], +): Promise { + return sourceDigest(await readAuthorityFiles(sources)); } export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Promise { await rm(snapshot.snapshotRoot, { recursive: true, force: true }); } -async function readSourceTree(root: string): Promise { +async function trustedSourceRoots( + projectRoot: string, + surfaceEntry: string, +): Promise { + const surfaceRoot = await packageRoot(surfaceEntry, '@relayflows/surface'); + return Object.freeze([ + Object.freeze({ root: projectRoot, prefix: '' }), + Object.freeze({ root: surfaceRoot, prefix: join('node_modules', '@relayflows/surface') }), + ]); +} + +async function packageRoot(entry: string, expectedName: string): Promise { + let current = dirname(await realpath(entry)); + for (;;) { + try { + const manifest = JSON.parse(await readFile(join(current, 'package.json'), 'utf8')) as { name?: unknown }; + if (manifest.name === expectedName) return current; + } catch { /* continue toward the filesystem root */ } + const parent = dirname(current); + if (parent === current) throw invalid(`Hosted base trusted dependency ${expectedName} has no package root.`); + current = parent; + } +} + +async function readAuthorityFiles(sources: readonly HostedBaseSourceRoot[]): Promise { + const files = (await Promise.all(sources.map(source => readTree(source.root, source.prefix)))).flat(); + files.sort((left, right) => left.path.localeCompare(right.path)); + const totalBytes = files.reduce((total, file) => total + file.bytes.byteLength, 0); + if (files.length > MAX_FILES || totalBytes > MAX_BYTES) { + throw invalid('Hosted base source and trusted dependencies exceed the snapshot limit.'); + } + return Object.freeze(files); +} + +async function readSnapshotTree(root: string): Promise { const files: SourceFile[] = []; - let totalBytes = 0; async function visit(directory: string, prefix: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const path = prefix === '' ? entry.name : join(prefix, entry.name); + const absolute = join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute, path); + else if (entry.isFile()) { + const bytes = await readFile(absolute); + files.push(Object.freeze({ path, bytes, sha256: sha256(bytes) })); + } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); + } + } + await visit(root, ''); + files.sort((left, right) => left.path.localeCompare(right.path)); + return Object.freeze(files); +} + +async function readTree(root: string, prefix: string): Promise { + const files: SourceFile[] = []; + async function visit(directory: string, relativeDirectory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; - const relativePath = prefix === '' ? entry.name : join(prefix, entry.name); + const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); const absolutePath = join(directory, entry.name); - if (entry.isDirectory()) { - await visit(absolutePath, relativePath); - } else if (entry.isFile()) { + if (entry.isDirectory()) await visit(absolutePath, relativePath); + else if (entry.isFile()) { const bytes = await readFile(absolutePath); - totalBytes += bytes.byteLength; - if (files.length >= MAX_FILES || totalBytes > MAX_BYTES) { - throw invalid('Hosted base source tree exceeds the snapshot limit.'); - } - files.push(Object.freeze({ path: relativePath, bytes, sha256: sha256(bytes) })); - } else { - throw invalid(`Hosted base source tree contains unsupported entry "${relativePath}".`); - } + files.push(Object.freeze({ + path: prefix === '' ? relativePath : join(prefix, relativePath), + bytes, + sha256: sha256(bytes), + })); + } else throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); } } try { await visit(root, ''); } catch (error) { if (error instanceof PluginError) throw error; - throw invalid('Hosted base source tree is unreadable.'); + throw invalid('Hosted base source or trusted dependency is unreadable.'); } return Object.freeze(files); } @@ -124,8 +238,3 @@ function sha256(value: Uint8Array | string): string { function invalid(message: string): PluginError { return new PluginError('plugin_source_invalid', message); } - -function isMissing(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error - && (error as { code?: unknown }).code === 'ENOENT'; -} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 548946b76..3c0ec3722 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,14 +1,12 @@ import { realpath } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { - hostedExtensionBaseFromLoadedFlow, - loadAuthoredFlow, -} from './authored-flow-loader.js'; import { canonicalize } from './canonical.js'; import { createHostedBaseSnapshot, + hostedBaseIdentityFromSnapshot, hostedBaseSourceDigest, removeHostedBaseSnapshot, + type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; import { PluginError } from './plugin-manifest.js'; import { findPluginProject } from './plugin-loader.js'; @@ -21,7 +19,7 @@ const BASE_AUTHORITY = new WeakSet(); interface RuntimeGeneration { readonly origin: string; declarations: string; - sourceRoot: string; + sourceRoots: readonly HostedBaseSourceRoot[]; sourceSha256: string; } @@ -177,14 +175,14 @@ function newGeneration(origin: string): RuntimeGeneration { return { origin, declarations: canonicalize([]), - sourceRoot: '', + sourceRoots: Object.freeze([]), sourceSha256: '', }; } async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { let currentSource: string; - try { currentSource = await hostedBaseSourceDigest(generation.sourceRoot); } + try { currentSource = await hostedBaseSourceDigest(generation.sourceRoots); } catch { throw new PluginError('plugin_source_invalid', 'Hosted extension runtime generation is no longer readable.'); } @@ -202,11 +200,10 @@ async function baseAt( generation: RuntimeGeneration, ): Promise { const snapshot = await createHostedBaseSnapshot(origin); - generation.sourceRoot = snapshot.liveRoot; + generation.sourceRoots = snapshot.liveSources; generation.sourceSha256 = snapshot.liveDigest; try { - const loaded = await loadAuthoredFlow(snapshot.snapshotFlowPath, { extensions: 'none' }); - const identity = hostedExtensionBaseFromLoadedFlow(loaded); + const identity = await hostedBaseIdentityFromSnapshot(snapshot); const value = Object.freeze({ name: identity.name, ...(identity.version === undefined ? {} : { version: identity.version }), diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 15012c846..900c2bdc3 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -272,7 +272,7 @@ describe('native Babysitter extension', () => { expect(calls).toBe(0); }); - it('refuses when live project bytes change while its private base snapshot imports', async () => { + it('refuses a base that tries to mutate the live project while its private snapshot imports', async () => { const racing = await composed(); const helperPath = join(racing.cwd, 'base-name.ts'); writeFileSync(racing.flowPath, ` @@ -284,7 +284,27 @@ describe('native Babysitter extension', () => { `); await expect(loadHostedExtensionRuntime(racing.flowPath)) .rejects.toMatchObject({ code: 'plugin_source_invalid' }); - expect(readFileSync(helperPath, 'utf8')).toContain('release-manager'); + expect(readFileSync(helperPath, 'utf8')).toContain('software-factory'); + }); + + it('refuses project-owned package imports instead of following live node_modules', async () => { + const racing = await composed(); + const packageRoot = join(racing.cwd, 'node_modules/local-identity'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'local-identity', type: 'module', exports: './index.js', + })); + writeFileSync(join(packageRoot, 'index.js'), `export const baseName = 'software-factory';\n`); + writeFileSync(racing.flowPath, ` + import { flow } from '@relayflows/surface'; + import { baseName } from 'local-identity'; + export default flow(baseName, async f => f.done('success')); + `); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + writeFileSync(join(packageRoot, 'index.js'), `export const baseName = 'release-manager';\n`); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); }); it('refuses a second installed extension that overlaps an action-specific route', async () => { diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts new file mode 100644 index 000000000..1931a864b --- /dev/null +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -0,0 +1,74 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createHostedBaseSnapshot, + hostedBaseIdentityFromSnapshot, + hostedBaseSourceDigest, + removeHostedBaseSnapshot, +} from '../src/hosted-base-snapshot.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'hosted-base-snapshot-test-')); + roots.push(root); + const project = join(root, 'project'); + const surface = join(root, 'trusted-surface'); + mkdirSync(project, { recursive: true }); + mkdirSync(join(surface, 'dist'), { recursive: true }); + writeFileSync(join(project, 'package.json'), '{"type":"module"}'); + const flowPath = join(project, 'software-factory.flow.ts'); + writeFileSync(flowPath, ` + import { flow } from '@relayflows/surface'; + export default flow('software-factory', async f => f.done('success')); + `); + writeFileSync(join(surface, 'package.json'), JSON.stringify({ + name: '@relayflows/surface', type: 'module', exports: './dist/index.js', + })); + writeFileSync(join(surface, 'dist/shared.js'), ` + const definitions = new WeakMap(); + export function flow(name) { + const handle = async () => {}; + definitions.set(handle, { name, header: {} }); + return handle; + } + export function getFlowDefinition(handle) { return definitions.get(handle); } + `); + writeFileSync(join(surface, 'dist/index.js'), `export { flow } from './shared.js';\n`); + writeFileSync(join(surface, 'dist/flow.js'), `export { getFlowDefinition } from './shared.js';\n`); + return { flowPath, surfaceEntry: join(surface, 'dist/index.js'), shared: join(surface, 'dist/shared.js') }; +} + +describe('hosted base private snapshot', () => { + it('imports copied host package bytes when the live package changes before import', async () => { + const { flowPath, surfaceEntry, shared } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); + try { + writeFileSync(shared, `throw new Error('live replacement executed');\n`); + await expect(hostedBaseIdentityFromSnapshot(snapshot)).resolves.toEqual({ + name: 'software-factory', + }); + await expect(hostedBaseSourceDigest(snapshot.liveSources)).resolves.not.toBe(snapshot.liveDigest); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + + it('detects a live host package change after the copied identity was loaded', async () => { + const { flowPath, surfaceEntry, shared } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); + try { + await expect(hostedBaseIdentityFromSnapshot(snapshot)).resolves.toEqual({ + name: 'software-factory', + }); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(snapshot.liveDigest); + writeFileSync(shared, `throw new Error('post-load replacement');\n`); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).not.toBe(snapshot.liveDigest); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 81575334f..b85d6d180 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -27,6 +27,7 @@ "tests/authored-flow.test.ts", "tests/authored-agent-permissions.test.ts", "tests/hosted-extension-isolation.test.ts", + "tests/hosted-base-snapshot.test.ts", "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", From 0f69d1bf92dc7c7eabdc995162191a51de2fc3eb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 07:27:06 -0700 Subject: [PATCH 10/80] test(sdk): refuse hosted base import escape Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 3 ++- .../verification.txt | 20 +++++++++---------- .../sdk/tests/hosted-base-snapshot.test.ts | 19 +++++++++++++++++- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 28f46290f..33c67e0d8 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -63,7 +63,8 @@ generation. The base, its ordinary relative imports, and the exact host-owned Surface package load from one rehashed private snapshot under Node's read-only permission model. Project `node_modules` is neither copied nor linked, and any base import other than the attested Surface package or its own relative source -fails resolution. Mutable dependency paths and an earlier Node module cache +fails resolution; relative imports that escape the project root are refused. +Mutable dependency paths and an earlier Node module cache therefore cannot change or substitute the identity that was admitted; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Cross-project, cross-redeploy, stale, diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index c2aee2963..b01256267 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,18 +19,18 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (2 tests) 121ms - ✓ tests/preflight.test.ts (59 tests) 63ms - ✓ tests/authored-flow.test.ts (34 tests) 737ms - ✓ tests/hosted-extension-isolation.test.ts (12 tests) 591ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 641ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 1279ms - ✓ native Babysitter extension > refuses project-owned package imports instead of following live node_modules 364ms + ✓ tests/hosted-base-snapshot.test.ts (3 tests) 170ms + ✓ tests/preflight.test.ts (59 tests) 88ms + ✓ tests/authored-flow.test.ts (34 tests) 769ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 676ms + ✓ tests/hosted-extension-isolation.test.ts (12 tests) 716ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 1362ms + ✓ native Babysitter extension > refuses project-owned package imports instead of following live node_modules 369ms Test Files 6 passed (6) - Tests 155 passed (155) - Start at 07:16:27 - Duration 2.25s (transform 1.05s, setup 0ms, collect 4.30s, tests 3.43s, environment 1ms, prepare 363ms) + Tests 156 passed (156) + Start at 07:26:07 + Duration 2.51s (transform 1.22s, setup 0ms, collect 5.03s, tests 3.78s, environment 1ms, prepare 432ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 1931a864b..d21295268 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { createHostedBaseSnapshot, @@ -71,4 +71,21 @@ describe('hosted base private snapshot', () => { await removeHostedBaseSnapshot(snapshot); } }); + + it('refuses a relative import outside the snapshotted project root', async () => { + const { flowPath, surfaceEntry } = fixture(); + writeFileSync(join(dirname(flowPath), '../outside-base.ts'), `export const name = 'software-factory';\n`); + writeFileSync(flowPath, ` + import { flow } from '@relayflows/surface'; + import { name } from '../outside-base.ts'; + export default flow(name, async f => f.done('success')); + `); + const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); + try { + await expect(hostedBaseIdentityFromSnapshot(snapshot)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); }); From 3bc362653a3bf1b96cab7c85607daad3aef826f0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 07:44:50 -0700 Subject: [PATCH 11/80] fix(sdk): pin hosted runtime identities Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 22 +++-- evidence/babysitter-gate8-isolation/README.md | 10 +- .../verification.txt | 46 +++++---- packages/sdk/src/hosted-base-snapshot.ts | 99 ++----------------- .../sdk/src/hosted-extension-isolation.ts | 11 ++- packages/sdk/src/hosted-extension-runtime.ts | 18 ++-- packages/sdk/src/hosted-extension-sandbox.ts | 74 +++++++++++--- .../tests/babysitter-native-extension.test.ts | 41 +++----- .../sdk/tests/hosted-base-snapshot.test.ts | 86 +++++----------- .../tests/hosted-extension-isolation.test.ts | 74 +++++++++++++- 10 files changed, 244 insertions(+), 237 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 33c67e0d8..b8d89f70e 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -30,13 +30,14 @@ not wired to hosted dispatch and must not be treated as enablement. The package' commit pinned below. The sandbox contract is deliberately narrower than #442. It re-verifies the -complete lock-backed installation and every manifest, binds it to the actual -base returned in the same `loadHostedExtensionRuntime` generation, detects +complete lock-backed installation and every manifest, binds it to the exact +reviewed Software Factory source in the same `loadHostedExtensionRuntime` generation, detects cross-extension route ambiguity, accepts only the exact published Babysitter ref/digest/manifest and native permission profile, imports the entry only inside Linux bubblewrap plus Node's permission model, mounts a minimal trusted Surface facade (`flow`, `github`, -and `getFlowDefinition`) instead of the general helper runtime, checks +and `getFlowDefinition`) from six integrity-pinned private runtime files instead +of the general helper runtime, checks normalized input against non-serializable verified dispatch authority, and permits one queue call. The parent capability adapter receives that original authority plus immutable extension provenance; the @@ -59,13 +60,14 @@ Before replacing #549's refusal, the hosted caller must obtain an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then call `runHostedCapabilityExtension` with both values. Every dispatch rechecks the current extension declarations and complete project source tree against that -generation. The base, its ordinary relative imports, and the exact host-owned -Surface package load from one rehashed private snapshot under Node's read-only -permission model. Project `node_modules` is neither copied nor linked, and any -base import other than the attested Surface package or its own relative source -fails resolution; relative imports that escape the project root are refused. -Mutable dependency paths and an earlier Node module cache -therefore cannot change or substitute the identity that was admitted; +generation. The loader never imports tenant base code to derive authority. It +requires the exact reviewed Software Factory flow-file SHA-256 and assigns its +pinned name/version in the parent; project `node_modules`, relative imports, +stdout, process termination, globals, and module caches therefore cannot forge +that identity. The sandbox separately requires exact SHA-256 pins for every +Surface runtime file it needs, copies those bytes into its private runtime, and +mounts only the copies. A final generation check runs after both private +snapshots exist and immediately before launch; using the ordinary compose loader would import extension top-level JavaScript in the host before the sandbox exists. Cross-project, cross-redeploy, stale, and structural pairings fail before import. The selected store bytes are copied into a diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index d695c1e22..40109a692 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -7,12 +7,13 @@ deployment. The enforced boundary is: -- load the actual base flow with extensions disabled and resolve the complete +- require the exact reviewed Software Factory base source hash without + importing tenant base code, and resolve the complete installation as one opaque generation without importing extension JavaScript; cross-project and same-path cross-redeploy pairing refuse, and every dispatch rechecks the current declarations plus complete project - source; the base and ordinary relative imports load from one unique private - snapshot, never mutable deployment paths or an earlier module cache; + source; base stdout, globals, process termination, relative/package imports, + and an earlier module cache cannot forge the parent-pinned identity; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the @@ -24,7 +25,8 @@ The enforced boundary is: plus Node's permission model, with no network, writable filesystem, inherited environment, child process, workspace mount, MCP, helpers, harnesses, or base flow context; a generated facade mounts only `flow`, `github`, - `getFlowDefinition`, and their six reviewed Surface runtime files; + `getFlowDefinition`, and private copies of six SHA-256-pinned Surface runtime + files, followed by one final runtime-generation check before launch; - expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and `done`, validate the exact request and `{ receiptId, status }` response in the parent, and pass the original non-serializable authority to the host adapter; diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index b01256267..66a7d26e6 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -19,20 +19,19 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (3 tests) 170ms - ✓ tests/preflight.test.ts (59 tests) 88ms - ✓ tests/authored-flow.test.ts (34 tests) 769ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 676ms - ✓ tests/hosted-extension-isolation.test.ts (12 tests) 716ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 1362ms - ✓ native Babysitter extension > refuses project-owned package imports instead of following live node_modules 369ms + ✓ tests/hosted-base-snapshot.test.ts (2 tests) 18ms + ✓ tests/preflight.test.ts (59 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 333ms + ✓ tests/authored-flow.test.ts (34 tests) 739ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 630ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 673ms Test Files 6 passed (6) - Tests 156 passed (156) - Start at 07:26:07 - Duration 2.51s (transform 1.22s, setup 0ms, collect 5.03s, tests 3.78s, environment 1ms, prepare 432ms) + Tests 157 passed (157) + Start at 07:42:04 + Duration 1.76s (transform 1.00s, setup 0ms, collect 4.17s, tests 2.49s, environment 1ms, prepare 417ms) -$ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test +$ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js > @relayflows/surface@2.0.26 typecheck > tsc --noEmit @@ -51,16 +50,23 @@ $ tsc ✓ tests/declined.test.ts (1 test) 2ms ✓ tests/triggers-github-events.test.ts (2 tests) 3ms - ✓ tests/helpers.snapshot.test.ts (1 test) 249ms - ✓ tests/triggers-all-providers.test.ts (4 tests) 36ms - ✓ tests/slack-block-kit.test.ts (5 tests) 3ms + ✓ tests/helpers.snapshot.test.ts (1 test) 168ms + ✓ tests/triggers-all-providers.test.ts (4 tests) 41ms + ✓ tests/slack-block-kit.test.ts (5 tests) 4ms ✓ tests/provider-triggers.test.ts (3 tests) 5ms - ✓ tests/triggers.test.ts (4 tests) 5ms - ✓ tests/flow.test.ts (24 tests) 10ms - ✓ tests/schedule.test.ts (7 tests) 4714ms - ✓ schedule.cron > measures a cron's longest quiet period so a silence budget can be declared honestly 4696ms + ✓ tests/triggers.test.ts (4 tests) 6ms + ✓ tests/flow.test.ts (24 tests) 9ms + ✓ tests/schedule.test.ts (7 tests) 7643ms + ✓ schedule.cron > measures a cron's longest quiet period so a silence budget can be declared honestly 7618ms Test Files 9 passed (9) Tests 51 passed (51) - Start at 05:18:30 - Duration 5.21s (transform 551ms, setup 0ms, collect 2.23s, tests 5.03s, environment 1ms, prepare 499ms) + Start at 07:42:17 + Duration 8.24s (transform 722ms, setup 0ms, collect 3.09s, tests 7.88s, environment 1ms, prepare 533ms) + +4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c dist/flow.js +7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f dist/helpers/providers.js +e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff dist/provider-trigger.js +8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b dist/schedule.js +4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c dist/triggers.js +e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b dist/triggers/github.js diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index c73f5bc3a..750bfa80b 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,11 +1,7 @@ -import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { promisify } from 'node:util'; import { canonicalize } from './canonical.js'; import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; @@ -13,8 +9,6 @@ import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); const MAX_FILES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; -const execFileAsync = promisify(execFile); -const hostRequire = createRequire(import.meta.url); interface SourceFile { readonly path: string; @@ -32,25 +26,14 @@ export interface HostedBaseSnapshot { readonly liveDigest: string; readonly snapshotRoot: string; readonly snapshotFlowPath: string; - readonly snapshotSurfaceDefinitionPath: string; -} - -export interface HostedBaseIdentity { - readonly name: string; - readonly version?: string; } /** - * Materialize project source plus the SDK-owned Surface package. Project - * node_modules is never linked or read by the imported base; any package other - * than the host's attested Surface fails resolution. Every executable file is + * Materialize project source without importing tenant code. Project + * node_modules is never linked or read. Every admitted source byte is * represented in liveDigest and copied to the private identity directory. */ -export async function createHostedBaseSnapshot( - flowPath: string, - /** @internal Test seam; hosted callers cannot supply trust roots. */ - surfaceEntry = hostRequire.resolve('@relayflows/surface'), -): Promise { +export async function createHostedBaseSnapshot(flowPath: string): Promise { const origin = await realpath(resolve(flowPath)); const discovered = findPluginProject(dirname(origin)) ?? dirname(origin); const projectRoot = await realpath(discovered); @@ -59,7 +42,9 @@ export async function createHostedBaseSnapshot( || flowRelative === '..' || flowRelative.startsWith(`..${sep}`)) { throw invalid('Hosted base flow must be a file inside its project root.'); } - const liveSources = await trustedSourceRoots(projectRoot, surfaceEntry); + const liveSources = Object.freeze([ + Object.freeze({ root: projectRoot, prefix: '' }), + ]); const files = await readAuthorityFiles(liveSources); const liveDigest = sourceDigest(files); const snapshotRoot = await mkdtemp(join(tmpdir(), 'flows-hosted-base-')); @@ -80,7 +65,6 @@ export async function createHostedBaseSnapshot( liveDigest, snapshotRoot, snapshotFlowPath: join(snapshotRoot, flowRelative), - snapshotSurfaceDefinitionPath: join(snapshotRoot, 'node_modules/@relayflows/surface/dist/flow.js'), }); } catch (error) { await rm(snapshotRoot, { recursive: true, force: true }); @@ -88,53 +72,6 @@ export async function createHostedBaseSnapshot( } } -/** Import the private snapshot in a child that cannot read fallback packages. */ -export async function hostedBaseIdentityFromSnapshot( - snapshot: HostedBaseSnapshot, -): Promise { - const bootstrap = ` - const [flowUrl, runtimeUrl] = JSON.parse(process.argv[1]); - const authored = await import(flowUrl); - const runtime = await import(runtimeUrl); - const definition = runtime.getFlowDefinition(authored.default); - const identity = {name: definition.name}; - if (definition.header.version !== undefined) identity.version = definition.header.version; - process.stdout.write(JSON.stringify(identity)); - `; - let stdout: string; - try { - ({ stdout } = await execFileAsync(process.execPath, [ - '--permission', - `--allow-fs-read=${snapshot.snapshotRoot}`, - '--experimental-strip-types', - '--disable-warning=ExperimentalWarning', - '--input-type=module', - '--eval', bootstrap, - JSON.stringify([ - pathToFileURL(snapshot.snapshotFlowPath).href, - pathToFileURL(snapshot.snapshotSurfaceDefinitionPath).href, - ]), - ], { cwd: snapshot.snapshotRoot, env: {}, timeout: 10_000, maxBuffer: 4096 })); - } catch { - throw invalid('Hosted base could not be imported from its private dependency snapshot.'); - } - let value: unknown; - try { value = JSON.parse(stdout); } - catch { throw invalid('Hosted base identity response is malformed.'); } - if (typeof value !== 'object' || value === null || Array.isArray(value) - || typeof (value as { name?: unknown }).name !== 'string' - || ((value as { version?: unknown }).version !== undefined - && typeof (value as { version?: unknown }).version !== 'string') - || Object.keys(value).some(key => !['name', 'version'].includes(key))) { - throw invalid('Hosted base identity response is malformed.'); - } - const identity = value as { name: string; version?: string }; - return Object.freeze({ - name: identity.name, - ...(identity.version === undefined ? {} : { version: identity.version }), - }); -} - export async function hostedBaseSourceDigest( sources: readonly HostedBaseSourceRoot[], ): Promise { @@ -145,30 +82,6 @@ export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Pr await rm(snapshot.snapshotRoot, { recursive: true, force: true }); } -async function trustedSourceRoots( - projectRoot: string, - surfaceEntry: string, -): Promise { - const surfaceRoot = await packageRoot(surfaceEntry, '@relayflows/surface'); - return Object.freeze([ - Object.freeze({ root: projectRoot, prefix: '' }), - Object.freeze({ root: surfaceRoot, prefix: join('node_modules', '@relayflows/surface') }), - ]); -} - -async function packageRoot(entry: string, expectedName: string): Promise { - let current = dirname(await realpath(entry)); - for (;;) { - try { - const manifest = JSON.parse(await readFile(join(current, 'package.json'), 'utf8')) as { name?: unknown }; - if (manifest.name === expectedName) return current; - } catch { /* continue toward the filesystem root */ } - const parent = dirname(current); - if (parent === current) throw invalid(`Hosted base trusted dependency ${expectedName} has no package root.`); - current = parent; - } -} - async function readAuthorityFiles(sources: readonly HostedBaseSourceRoot[]): Promise { const files = (await Promise.all(sources.map(source => readTree(source.root, source.prefix)))).flat(); files.sort((left, right) => left.path.localeCompare(right.path)); diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 84aa18207..b39bb8a55 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -105,7 +105,12 @@ export async function runHostedCapabilityExtension( identity, runtimeVersions(), ); - return await runVerifiedNativeExtensionSandbox({ ...options, artifact, manifest }); + return await runVerifiedNativeExtensionSandbox({ + ...options, + artifact, + manifest, + beforeLaunch: () => assertHostedRuntimeAuthority(options.installation, options.base), + }); } interface RunVerifiedNativeExtensionOptions { @@ -117,6 +122,8 @@ interface RunVerifiedNativeExtensionOptions { readonly timeoutMs?: number; readonly bubblewrapPath?: string; readonly nodePath?: string; + readonly surfaceRoot?: string; + readonly beforeLaunch?: () => Promise; } /** @internal Security-harness seam; not exported from the SDK package root. */ @@ -147,6 +154,8 @@ export async function runVerifiedNativeExtensionSandbox( timeoutMs: options.timeoutMs, bubblewrapPath: options.bubblewrapPath, nodePath: options.nodePath, + surfaceRoot: options.surfaceRoot, + beforeLaunch: options.beforeLaunch, invoke: async request => babysitterReceipt(await options.babysitterTurn.queue( babysitterRequest(request, normalizedInput, options.dispatch), authority, diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 3c0ec3722..5e16c068b 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,9 +1,9 @@ -import { realpath } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; import { createHostedBaseSnapshot, - hostedBaseIdentityFromSnapshot, hostedBaseSourceDigest, removeHostedBaseSnapshot, type HostedBaseSourceRoot, @@ -15,6 +15,7 @@ import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; const INSTALLATION_AUTHORITY = new WeakSet(); const BASE_AUTHORITY = new WeakSet(); +const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; interface RuntimeGeneration { readonly origin: string; @@ -203,11 +204,14 @@ async function baseAt( generation.sourceRoots = snapshot.liveSources; generation.sourceSha256 = snapshot.liveDigest; try { - const identity = await hostedBaseIdentityFromSnapshot(snapshot); - const value = Object.freeze({ - name: identity.name, - ...(identity.version === undefined ? {} : { version: identity.version }), - }); + const baseBytes = await readFile(snapshot.snapshotFlowPath); + if (createHash('sha256').update(baseBytes).digest('hex') !== SOFTWARE_FACTORY_SHA256) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted capability isolation accepts only the reviewed Software Factory base source.', + ); + } + const value = Object.freeze({ name: 'software-factory', version: '2.0.22' }); BASE_AUTHORITY.add(value); BASE_GENERATION.set(value, generation); return value; diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 5266465f9..a73d180a8 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; @@ -15,6 +16,14 @@ import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const DEFAULT_TIMEOUT_MS = 10_000; +const SURFACE_RUNTIME_SHA256 = Object.freeze({ + 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', + 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', + 'provider-trigger.js': 'e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff', + 'schedule.js': '8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b', + 'triggers.js': '4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c', + 'triggers/github.js': 'e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b', +}); export interface RunHostedExtensionSandboxOptions { readonly artifactDirectory: string; @@ -27,6 +36,10 @@ export interface RunHostedExtensionSandboxOptions { readonly timeoutMs?: number; readonly bubblewrapPath?: string; readonly nodePath?: string; + /** @internal Test seam for a package root with the exact pinned runtime bytes. */ + readonly surfaceRoot?: string; + /** @internal Last parent-side authority check after private snapshots exist. */ + readonly beforeLaunch?: () => Promise; } export async function runHostedExtensionSandbox( @@ -46,7 +59,7 @@ export async function runHostedExtensionSandbox( if (!entryPath.startsWith(`${resolve(options.artifactDirectory)}/`)) { throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); } - const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion); + const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion, options.surfaceRoot); const runtimeDirectory = await mkdtemp(join(tmpdir(), 'flows-hosted-extension-')); const runner = join(runtimeDirectory, 'runner.mjs'); const surfaceFacade = join(runtimeDirectory, 'surface'); @@ -64,14 +77,14 @@ export async function runHostedExtensionSandbox( ); } await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); - await writeSurfaceFacade(surfaceFacade); + await writeSurfaceFacade(surfaceFacade, surfaceRoot); const args = sandboxArguments({ node, runner, extension: realpathSync(snapshot.directory), surfaceFacade, - surfaceRoot, }); + await options.beforeLaunch?.(); const child = spawn(bwrap, args, { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'] }); return await exchangeHostedExtension( child, @@ -94,7 +107,8 @@ export async function runHostedExtensionSandbox( } } -function resolveSurfaceRoot(expectedVersion: string): string { +function resolveSurfaceRoot(expectedVersion: string, override?: string): string { + if (override !== undefined) return checkedSurfaceRoot(override, expectedVersion); let resolved: string; try { resolved = realpathSync(createRequire(import.meta.url).resolve('@relayflows/surface')); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } @@ -115,8 +129,33 @@ function resolveSurfaceRoot(expectedVersion: string): string { return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } -async function writeSurfaceFacade(directory: string): Promise { - await mkdir(directory); +function checkedSurfaceRoot(root: string, expectedVersion: string): string { + let real: string; + try { real = realpathSync(root); } + catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } + try { + const manifest = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8')) as { + name?: unknown; version?: unknown; + }; + if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) return real; + } catch { /* fall through */ } + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); +} + +async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promise { + await Promise.all([ + mkdir(join(directory, 'dist/helpers'), { recursive: true }), + mkdir(join(directory, 'dist/triggers'), { recursive: true }), + ]); + const runtimeFiles = Object.entries(SURFACE_RUNTIME_SHA256).map(([file, expected]) => { + let bytes: Buffer; + try { bytes = readFileSync(join(surfaceRoot, 'dist', file)); } + catch { return unsupported(`hosted extension cannot read pinned Surface runtime ${file}`); } + if (sha256(bytes) !== expected) { + return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); + } + return { file, bytes, expected }; + }); await Promise.all([ writeFile(join(directory, 'package.json'), JSON.stringify({ name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, @@ -126,11 +165,18 @@ async function writeSurfaceFacade(directory: string): Promise { { mode: 0o400, flag: 'wx' }), writeFile(join(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", { mode: 0o400, flag: 'wx' }), + ...runtimeFiles.map(({ file, bytes }) => writeFile(join(directory, 'dist', file), bytes, + { mode: 0o400, flag: 'wx' })), ]); + for (const { file, expected } of runtimeFiles) { + if (sha256(readFileSync(join(directory, 'dist', file))) !== expected) { + throw new PluginError('plugin_source_drift', `Private Surface runtime snapshot changed at ${file}.`); + } + } } function sandboxArguments(input: { - node: string; runner: string; extension: string; surfaceFacade: string; surfaceRoot: string; + node: string; runner: string; extension: string; surfaceFacade: string; }): string[] { const args = [ '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', '--dir', '/usr', @@ -149,7 +195,7 @@ function sandboxArguments(input: { '--dir', '/extension/node_modules/@relayflows/surface/dist', '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', - ...surfaceRuntimeMounts(input.surfaceRoot), + ...surfaceRuntimeMounts(input.surfaceFacade), '--ro-bind', input.extension, '/extension/src', '--chdir', '/extension/src', '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', @@ -158,15 +204,17 @@ function sandboxArguments(input: { return args; } -function surfaceRuntimeMounts(surfaceRoot: string): string[] { - return [ - 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', 'triggers.js', 'triggers/github.js', - ].flatMap(file => [ - '--ro-bind', realpathSync(join(surfaceRoot, 'dist', file)), +function surfaceRuntimeMounts(surfaceFacade: string): string[] { + return Object.keys(SURFACE_RUNTIME_SHA256).flatMap(file => [ + '--ro-bind', realpathSync(join(surfaceFacade, 'dist', file)), `/extension/node_modules/@relayflows/surface/dist/${file}`, ]); } +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + function executable(path: string, name: string): string { let real: string; try { real = realpathSync(path); } diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 900c2bdc3..2205dd4f0 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -49,12 +49,10 @@ async function composed() { symlinkSync(resolve('node_modules/@relayflows/surface'), join(cwd, 'node_modules/@relayflows/surface')); writeFileSync(join(cwd, 'package.json'), '{"type":"module"}'); writeFileSync(join(cwd, 'flows.json'), JSON.stringify({ cli: 'codex', executors: ['github'] })); - writeFileSync(join(cwd, 'base-name.ts'), `export const baseName = 'software-factory';\n`); - writeFileSync(join(cwd, 'software-factory.flow.ts'), ` - import { flow } from '@relayflows/surface'; - import { baseName } from './base-name.ts'; - export default flow(baseName, { budget: { dollars: 10, wallclock: '1h' } }, async f => { f.done('success'); }); - `); + writeFileSync( + join(cwd, 'software-factory.flow.ts'), + readFileSync(resolve('../..', 'examples/software-factory/software-factory.flow.ts')), + ); const io = { stdout: () => {}, stderr: (s: string) => { throw new Error(s); } }; expect(await addExtensionPlugin(REF, io, { cwd, fetch: github().fetch, now, versions })).toBe(0); const loaded = await loadAuthoredFlow(join(cwd, 'software-factory.flow.ts'), { versions }); @@ -228,10 +226,10 @@ describe('native Babysitter extension', () => { it('refuses stale installation authority after the same flow path is redeployed', async () => { const configPath = join(installed.cwd, 'flows.json'); const lockPath = join(installed.cwd, 'flows.lock.json'); - const helperPath = join(installed.cwd, 'base-name.ts'); + const flowPath = installed.flowPath; const originalConfig = readFileSync(configPath, 'utf8'); const originalLock = readFileSync(lockPath, 'utf8'); - const originalHelper = readFileSync(helperPath, 'utf8'); + const originalFlow = readFileSync(flowPath, 'utf8'); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', }); @@ -247,16 +245,9 @@ describe('native Babysitter extension', () => { lock.plugins = []; writeFileSync(configPath, JSON.stringify(config)); writeFileSync(lockPath, JSON.stringify(lock)); - writeFileSync(helperPath, `export const baseName = 'software-factory-redeployed';\n`); - const redeployed = await loadHostedExtensionRuntime(installed.flowPath); - expect(redeployed.base.name).toBe('software-factory-redeployed'); - await expect(runHostedCapabilityExtension({ - installation: installed.hostedRuntime.installation, - base: redeployed.base, - dispatch, - input: descriptor('pull_request.labeled'), - babysitterTurn: capability, - })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + writeFileSync(flowPath, `export default {};\n`); + await expect(loadHostedExtensionRuntime(installed.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); await expect(runHostedCapabilityExtension({ installation: installed.hostedRuntime.installation, base: installed.hostedRuntime.base, @@ -267,24 +258,20 @@ describe('native Babysitter extension', () => { } finally { writeFileSync(configPath, originalConfig); writeFileSync(lockPath, originalLock); - writeFileSync(helperPath, originalHelper); + writeFileSync(flowPath, originalFlow); } expect(calls).toBe(0); }); - it('refuses a base that tries to mutate the live project while its private snapshot imports', async () => { + it('never imports tenant base code to derive hosted authority', async () => { const racing = await composed(); - const helperPath = join(racing.cwd, 'base-name.ts'); writeFileSync(racing.flowPath, ` - import { writeFileSync } from 'node:fs'; - import { flow } from '@relayflows/surface'; - import { baseName } from './base-name.ts'; - writeFileSync(${JSON.stringify(helperPath)}, "export const baseName = 'release-manager';\\n"); - export default flow(baseName, async f => f.done('success')); + JSON.stringify = () => '{"name":"software-factory"}'; + process.stdout.write('{"name":"software-factory"}'); + process.exit(0); `); await expect(loadHostedExtensionRuntime(racing.flowPath)) .rejects.toMatchObject({ code: 'plugin_source_invalid' }); - expect(readFileSync(helperPath, 'utf8')).toContain('software-factory'); }); it('refuses project-owned package imports instead of following live node_modules', async () => { diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index d21295268..f22aa7eba 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,10 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { createHostedBaseSnapshot, - hostedBaseIdentityFromSnapshot, hostedBaseSourceDigest, removeHostedBaseSnapshot, } from '../src/hosted-base-snapshot.js'; @@ -13,77 +12,42 @@ const roots: string[] = []; afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); function fixture() { - const root = mkdtempSync(join(tmpdir(), 'hosted-base-snapshot-test-')); - roots.push(root); - const project = join(root, 'project'); - const surface = join(root, 'trusted-surface'); - mkdirSync(project, { recursive: true }); - mkdirSync(join(surface, 'dist'), { recursive: true }); + const project = mkdtempSync(join(tmpdir(), 'hosted-base-snapshot-test-')); + roots.push(project); writeFileSync(join(project, 'package.json'), '{"type":"module"}'); + writeFileSync(join(project, 'flows.json'), '{}'); const flowPath = join(project, 'software-factory.flow.ts'); - writeFileSync(flowPath, ` - import { flow } from '@relayflows/surface'; - export default flow('software-factory', async f => f.done('success')); - `); - writeFileSync(join(surface, 'package.json'), JSON.stringify({ - name: '@relayflows/surface', type: 'module', exports: './dist/index.js', - })); - writeFileSync(join(surface, 'dist/shared.js'), ` - const definitions = new WeakMap(); - export function flow(name) { - const handle = async () => {}; - definitions.set(handle, { name, header: {} }); - return handle; - } - export function getFlowDefinition(handle) { return definitions.get(handle); } - `); - writeFileSync(join(surface, 'dist/index.js'), `export { flow } from './shared.js';\n`); - writeFileSync(join(surface, 'dist/flow.js'), `export { getFlowDefinition } from './shared.js';\n`); - return { flowPath, surfaceEntry: join(surface, 'dist/index.js'), shared: join(surface, 'dist/shared.js') }; + const source = `throw new Error('tenant base must not execute');\n`; + writeFileSync(flowPath, source); + return { project, flowPath, source }; } describe('hosted base private snapshot', () => { - it('imports copied host package bytes when the live package changes before import', async () => { - const { flowPath, surfaceEntry, shared } = fixture(); - const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); - try { - writeFileSync(shared, `throw new Error('live replacement executed');\n`); - await expect(hostedBaseIdentityFromSnapshot(snapshot)).resolves.toEqual({ - name: 'software-factory', - }); - await expect(hostedBaseSourceDigest(snapshot.liveSources)).resolves.not.toBe(snapshot.liveDigest); - } finally { - await removeHostedBaseSnapshot(snapshot); - } - }); - - it('detects a live host package change after the copied identity was loaded', async () => { - const { flowPath, surfaceEntry, shared } = fixture(); - const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); + it('keeps the buffered base bytes when the live source changes', async () => { + const { flowPath, source } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath); try { - await expect(hostedBaseIdentityFromSnapshot(snapshot)).resolves.toEqual({ - name: 'software-factory', - }); - expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(snapshot.liveDigest); - writeFileSync(shared, `throw new Error('post-load replacement');\n`); + writeFileSync(flowPath, `throw new Error('live replacement');\n`); + expect(readFileSync(snapshot.snapshotFlowPath, 'utf8')).toBe(source); expect(await hostedBaseSourceDigest(snapshot.liveSources)).not.toBe(snapshot.liveDigest); } finally { await removeHostedBaseSnapshot(snapshot); } }); - it('refuses a relative import outside the snapshotted project root', async () => { - const { flowPath, surfaceEntry } = fixture(); - writeFileSync(join(dirname(flowPath), '../outside-base.ts'), `export const name = 'software-factory';\n`); - writeFileSync(flowPath, ` - import { flow } from '@relayflows/surface'; - import { name } from '../outside-base.ts'; - export default flow(name, async f => f.done('success')); - `); - const snapshot = await createHostedBaseSnapshot(flowPath, surfaceEntry); + it('excludes project node_modules from the admitted generation', async () => { + const { project, flowPath } = fixture(); + const dependency = join(project, 'node_modules/local-identity'); + mkdirSync(dependency, { recursive: true }); + writeFileSync(join(dependency, 'package.json'), '{"name":"local-identity"}'); + writeFileSync(join(dependency, 'index.js'), `throw new Error('must not execute');\n`); + const snapshot = await createHostedBaseSnapshot(flowPath); try { - await expect(hostedBaseIdentityFromSnapshot(snapshot)) - .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(() => readFileSync(join(snapshot.snapshotRoot, 'node_modules/local-identity/index.js'))) + .toThrow(); + const before = await hostedBaseSourceDigest(snapshot.liveSources); + writeFileSync(join(dependency, 'index.js'), `throw new Error('replacement');\n`); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(before); } finally { await removeHostedBaseSnapshot(snapshot); } diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index d456b9a02..e44bca437 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -1,5 +1,13 @@ import { createHash } from 'node:crypto'; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -79,6 +87,24 @@ async function artifact(source = ordinaryExtension, value = manifest()): Promise }; } +function surfaceFixture(): string { + const sourceRoot = resolve('../surface'); + const surfaceRoot = mkdtempSync(join(tmpdir(), 'hosted-surface-test-')); + roots.push(surfaceRoot); + writeFileSync(join(surfaceRoot, 'package.json'), JSON.stringify({ + name: '@relayflows/surface', version: '2.0.26', type: 'module', + })); + for (const file of [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', + 'schedule.js', 'triggers.js', 'triggers/github.js', + ]) { + const target = join(surfaceRoot, 'dist', file); + mkdirSync(resolve(target, '..'), { recursive: true }); + copyFileSync(join(sourceRoot, 'dist', file), target); + } + return surfaceRoot; +} + /** Emit raw parent-protocol frames during import, before any handler can run. */ function hostileImport(frames: readonly unknown[]): string { const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); @@ -179,6 +205,52 @@ process.exit(child.status ?? 1); expect(readFileSync(join(installed.directory, 'babysitter.flow.ts'), 'utf8')).toBe(replacementSource); }); + it('mounts pinned private Surface bytes when the live package changes before launch', async () => { + const surfaceRoot = surfaceFixture(); + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + surfaceRoot, + beforeLaunch: async () => { + writeFileSync(join(surfaceRoot, 'dist/flow.js'), `throw new Error('live Surface executed');\n`); + }, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toBe(1); + expect(readFileSync(join(surfaceRoot, 'dist/flow.js'), 'utf8')).toContain('live Surface executed'); + }); + + it('refuses Surface runtime bytes that differ from the reviewed pin before launch', async () => { + const surfaceRoot = surfaceFixture(); + writeFileSync(join(surfaceRoot, 'dist/flow.js'), `throw new Error('unreviewed Surface');\n`); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + surfaceRoot, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { const source = ` import { flow, github } from '@relayflows/surface'; From 040a6ffef903398659cedb9caf8fb59f0d687848 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 07:59:05 -0700 Subject: [PATCH 12/80] fix(sdk): hard-limit hosted extension memory Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 12 +++++++-- evidence/babysitter-gate8-isolation/README.md | 4 ++- .../verification.txt | 21 ++++++++------- .../sdk/src/hosted-extension-isolation.ts | 6 ++++- packages/sdk/src/hosted-extension-sandbox.ts | 11 +++++++- .../tests/babysitter-native-extension.test.ts | 6 +++-- .../tests/hosted-extension-isolation.test.ts | 26 +++++++++++++++++++ 7 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index b8d89f70e..080a35132 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -27,7 +27,9 @@ workspace, environment credentials, network, helpers, MCP, or harnesses. It is not wired to hosted dispatch and must not be treated as enablement. The package's `compat` requires the published 2.0.26 Surface/SDK release that routes `labeled`, `unlabeled`, and `ready_for_review`. Export it only from the reviewed -commit pinned below. +release commit pinned below. The Software Factory flow's own independently +versioned header remains `2.0.22`; a regression requires the hosted identity to +equal the identity obtained from that exact reviewed source. The sandbox contract is deliberately narrower than #442. It re-verifies the complete lock-backed installation and every manifest, binds it to the exact @@ -35,7 +37,8 @@ reviewed Software Factory source in the same `loadHostedExtensionRuntime` genera cross-extension route ambiguity, accepts only the exact published Babysitter ref/digest/manifest and native permission profile, imports the entry only inside Linux bubblewrap plus Node's -permission model, mounts a minimal trusted Surface facade (`flow`, `github`, +permission model under an inherited 1.5 GiB hard address-space limit, mounts a +minimal trusted Surface facade (`flow`, `github`, and `getFlowDefinition`) from six integrity-pinned private runtime files instead of the general helper runtime, checks normalized input against non-serializable verified dispatch authority, and @@ -77,6 +80,11 @@ must prove this path at the exact release head. Broader per-agent-step file/network/access-preset enforcement remains open in #442 and is not claimed by this slice. +The reviewed native source ref is +`github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter`, +the commit included in the published 2.0.26 install. The earlier byte-identical +pre-release commit is not accepted as authority. + ## Export reviewed bytes After the native package is reviewed and committed, build the SDK and run: diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 40109a692..12afaf3b0 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -26,7 +26,9 @@ The enforced boundary is: environment, child process, workspace mount, MCP, helpers, harnesses, or base flow context; a generated facade mounts only `flow`, `github`, `getFlowDefinition`, and private copies of six SHA-256-pinned Surface runtime - files, followed by one final runtime-generation check before launch; + files, followed by one final runtime-generation check before launch; an + inherited 1.5 GiB hard address-space limit covers heap, Buffer/native memory, + mappings, and descendants in addition to the 64 MiB V8 old-space setting; - expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and `done`, validate the exact request and `{ receiptId, status }` response in the parent, and pass the original non-serializable authority to the host adapter; diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 66a7d26e6..f1a209788 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -2,8 +2,9 @@ Native Babysitter gate-8 isolation — literal local verification Host: Linux sf-frame Date: 2026-09-22 -$ /usr/bin/bwrap --version && node --version && git diff --check +$ /usr/bin/bwrap --version && /usr/bin/prlimit --version | head -1 && node --version && git diff --check bubblewrap 0.12.0 +prlimit from util-linux 2.42.3 v26.8.1 $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-base-snapshot.test.ts tests/hosted-extension-isolation.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts @@ -19,17 +20,17 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (2 tests) 18ms - ✓ tests/preflight.test.ts (59 tests) 99ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 333ms - ✓ tests/authored-flow.test.ts (34 tests) 739ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 630ms - ✓ tests/hosted-extension-isolation.test.ts (14 tests) 673ms + ✓ tests/hosted-base-snapshot.test.ts (2 tests) 11ms + ✓ tests/preflight.test.ts (59 tests) 67ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 387ms + ✓ tests/authored-flow.test.ts (34 tests) 758ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 830ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 972ms Test Files 6 passed (6) - Tests 157 passed (157) - Start at 07:42:04 - Duration 1.76s (transform 1.00s, setup 0ms, collect 4.17s, tests 2.49s, environment 1ms, prepare 417ms) + Tests 158 passed (158) + Start at 07:58:01 + Duration 2.11s (transform 1.09s, setup 0ms, collect 4.43s, tests 3.03s, environment 1ms, prepare 375ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index b39bb8a55..919b236f9 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -36,7 +36,7 @@ export type { } from './hosted-extension-runtime.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; -const BABYSITTER_REF = 'github:AgentWorkforce/flows@d3ee3b55ae636518dd4ad562aca5bae9c99f1a05#extensions/babysitter'; +const BABYSITTER_REF = 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter'; const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; @@ -79,6 +79,8 @@ export interface RunHostedExtensionOptions { readonly bubblewrapPath?: string; /** Test/packaging override. Defaults to the current, fingerprinted Node executable. */ readonly nodePath?: string; + /** Test/packaging override. Production resolves /usr/bin/prlimit. */ + readonly prlimitPath?: string; } export type HostedExtensionResult = HostedExtensionProtocolResult; @@ -122,6 +124,7 @@ interface RunVerifiedNativeExtensionOptions { readonly timeoutMs?: number; readonly bubblewrapPath?: string; readonly nodePath?: string; + readonly prlimitPath?: string; readonly surfaceRoot?: string; readonly beforeLaunch?: () => Promise; } @@ -154,6 +157,7 @@ export async function runVerifiedNativeExtensionSandbox( timeoutMs: options.timeoutMs, bubblewrapPath: options.bubblewrapPath, nodePath: options.nodePath, + prlimitPath: options.prlimitPath, surfaceRoot: options.surfaceRoot, beforeLaunch: options.beforeLaunch, invoke: async request => babysitterReceipt(await options.babysitterTurn.queue( diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index a73d180a8..b34a9a7d8 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -16,6 +16,11 @@ import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const DEFAULT_TIMEOUT_MS = 10_000; +// RLIMIT_AS is inherited across prlimit -> bubblewrap -> Node and covers V8, +// Buffer/native allocations, mappings, and any descendants. 1.5 GiB leaves +// room for Node's reserved code ranges while deterministically refusing a +// hostile 256 MiB Buffer under the separately constrained 64 MiB old-space. +const ADDRESS_SPACE_BYTES = 1_536 * 1024 * 1024; const SURFACE_RUNTIME_SHA256 = Object.freeze({ 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', @@ -36,6 +41,7 @@ export interface RunHostedExtensionSandboxOptions { readonly timeoutMs?: number; readonly bubblewrapPath?: string; readonly nodePath?: string; + readonly prlimitPath?: string; /** @internal Test seam for a package root with the exact pinned runtime bytes. */ readonly surfaceRoot?: string; /** @internal Last parent-side authority check after private snapshots exist. */ @@ -50,6 +56,7 @@ export async function runHostedExtensionSandbox( return unsupported(`hosted extension isolation does not support Node ${process.versions.node}`); } const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); + const prlimit = executable(options.prlimitPath ?? '/usr/bin/prlimit', 'prlimit'); const node = executable(options.nodePath ?? process.execPath, 'Node'); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { @@ -85,7 +92,9 @@ export async function runHostedExtensionSandbox( surfaceFacade, }); await options.beforeLaunch?.(); - const child = spawn(bwrap, args, { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'] }); + const child = spawn(prlimit, [`--as=${ADDRESS_SPACE_BYTES}`, '--', bwrap, ...args], { + cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'], + }); return await exchangeHostedExtension( child, child.stdio[3] as Readable, diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 2205dd4f0..5a7db8a3d 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -21,7 +21,7 @@ import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; import { entriesFromDirectory, fakeGithub } from './fake-github.js'; const PATH = 'extensions/babysitter'; -const NATIVE_SHA = 'd3ee3b55ae636518dd4ad562aca5bae9c99f1a05'; +const NATIVE_SHA = '8b33ebab8347514f80d9da5a81206a087f641714'; const REF = `github:AgentWorkforce/flows@${NATIVE_SHA}#${PATH}`; const DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; @@ -105,7 +105,9 @@ async function handle(eventType: string, input: unknown, f: Ctx) { describe('native Babysitter extension', () => { it('composes onto Software Factory with exactly the declared, deliverable subscriptions', async () => { - const { loaded, extension } = installed; + const { loaded, extension, base, hostedRuntime } = installed; + expect(base).toEqual({ name: 'software-factory', version: '2.0.22' }); + expect(hostedRuntime.base).toEqual(base); expect(extension.manifest.permissions).toEqual({ integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'], budget: { dollars: 1, wallclock: '5m' }, diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index e44bca437..9b4927367 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -332,6 +332,32 @@ export default flow('babysitter', async f => f.done('declined')) expect(() => readFileSync(escaped)).toThrow(); }); + it('enforces an OS address-space bound on native Buffer allocation', async () => { + const installed = await artifact(` + const allocation = Buffer.alloc(256 * 1024 * 1024, 1); + if (allocation.byteLength !== 256 * 1024 * 1024) throw new Error('short allocation'); + ${ordinaryExtension} + `); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('Failed to allocate memory'), + }); + expect(calls).toBe(0); + }); + it('blocks extra handler fields and authority-bearing receipt fields at the parent port', async () => { const source = ` import { flow, github } from '@relayflows/surface'; From 84a97b7c3c01fcfcdb22b24e44ef6be4a1fff96f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 08:05:41 -0700 Subject: [PATCH 13/80] fix(sdk): bound hosted source reads Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 4 +- evidence/babysitter-gate8-isolation/README.md | 4 +- .../verification.txt | 18 +++--- packages/sdk/src/hosted-base-snapshot.ts | 54 +++++++++++++----- .../sdk/tests/hosted-base-snapshot.test.ts | 13 ++++- .../tests/hosted-extension-protocol.test.ts | 55 +++++++++++-------- 6 files changed, 101 insertions(+), 47 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 080a35132..7537c4af9 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -63,7 +63,9 @@ Before replacing #549's refusal, the hosted caller must obtain an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then call `runHostedCapabilityExtension` with both values. Every dispatch rechecks the current extension declarations and complete project source tree against that -generation. The loader never imports tenant base code to derive authority. It +generation. File-count and cumulative-byte bounds are enforced from no-follow +file metadata before source contents are buffered. The loader never imports +tenant base code to derive authority. It requires the exact reviewed Software Factory flow-file SHA-256 and assigns its pinned name/version in the parent; project `node_modules`, relative imports, stdout, process termination, globals, and module caches therefore cannot forge diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 12afaf3b0..9543263de 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -13,7 +13,9 @@ The enforced boundary is: JavaScript; cross-project and same-path cross-redeploy pairing refuse, and every dispatch rechecks the current declarations plus complete project source; base stdout, globals, process termination, relative/package imports, - and an earlier module cache cannot forge the parent-pinned identity; + and an earlier module cache cannot forge the parent-pinned identity; source + file-count and byte limits are enforced from no-follow file metadata before + contents are buffered; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index f1a209788..8c9e6fb91 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -20,17 +20,17 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (2 tests) 11ms - ✓ tests/preflight.test.ts (59 tests) 67ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 387ms - ✓ tests/authored-flow.test.ts (34 tests) 758ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 830ms - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 972ms + ✓ tests/hosted-base-snapshot.test.ts (3 tests) 19ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 386ms + ✓ tests/authored-flow.test.ts (34 tests) 752ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 798ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 1191ms Test Files 6 passed (6) - Tests 158 passed (158) - Start at 07:58:01 - Duration 2.11s (transform 1.09s, setup 0ms, collect 4.43s, tests 3.03s, environment 1ms, prepare 375ms) + Tests 159 passed (159) + Start at 08:05:03 + Duration 2.22s (transform 978ms, setup 0ms, collect 3.85s, tests 3.25s, environment 1ms, prepare 310ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 750bfa80b..3e286806d 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { chmod, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; @@ -16,6 +17,11 @@ interface SourceFile { readonly sha256: string; } +interface SourceBudget { + files: number; + bytes: number; +} + export interface HostedBaseSourceRoot { readonly root: string; readonly prefix: string; @@ -83,12 +89,10 @@ export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Pr } async function readAuthorityFiles(sources: readonly HostedBaseSourceRoot[]): Promise { - const files = (await Promise.all(sources.map(source => readTree(source.root, source.prefix)))).flat(); + const budget: SourceBudget = { files: 0, bytes: 0 }; + const files: SourceFile[] = []; + for (const source of sources) files.push(...await readTree(source.root, source.prefix, budget)); files.sort((left, right) => left.path.localeCompare(right.path)); - const totalBytes = files.reduce((total, file) => total + file.bytes.byteLength, 0); - if (files.length > MAX_FILES || totalBytes > MAX_BYTES) { - throw invalid('Hosted base source and trusted dependencies exceed the snapshot limit.'); - } return Object.freeze(files); } @@ -112,7 +116,11 @@ async function readSnapshotTree(root: string): Promise { return Object.freeze(files); } -async function readTree(root: string, prefix: string): Promise { +async function readTree( + root: string, + prefix: string, + budget: SourceBudget, +): Promise { const files: SourceFile[] = []; async function visit(directory: string, relativeDirectory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); @@ -123,12 +131,28 @@ async function readTree(root: string, prefix: string): Promise MAX_FILES) throw tooLarge(); + const handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size < 0n + || before.size > BigInt(MAX_BYTES - budget.bytes)) throw tooLarge(); + budget.bytes += Number(before.size); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if (bytes.byteLength !== Number(before.size) || after.size !== before.size + || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + files.push(Object.freeze({ + path: prefix === '' ? relativePath : join(prefix, relativePath), + bytes, + sha256: sha256(bytes), + })); + } finally { + await handle.close(); + } } else throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); } } @@ -140,6 +164,10 @@ async function readTree(root: string, prefix: string): Promise ({ path: file.path, sha256: file.sha256 })))); } diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index f22aa7eba..b87ee4fbb 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -52,4 +52,15 @@ describe('hosted base private snapshot', () => { await removeHostedBaseSnapshot(snapshot); } }); + + it('refuses an oversized source file before buffering its contents', async () => { + const { project, flowPath } = fixture(); + const oversized = join(project, 'oversized.bin'); + writeFileSync(oversized, ''); + truncateSync(oversized, 64 * 1024 * 1024 + 1); + await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('snapshot file or byte limit'), + }); + }); }); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index c354bc94b..9680c1cc7 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -83,22 +83,33 @@ const dispatch = () => hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); +async function waitForInvocation(invoked: Promise): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('hostile child did not invoke the adapter')), 10_000); + void invoked.then(() => { clearTimeout(timeout); resolve(); }, reject); + }); +} + describe('hosted extension hostile protocol', () => { it('rejects completion while an exact direct call is pending', async () => { let settle!: (value: unknown) => void; const adapter = new Promise(resolve => { settle = resolve; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); let calls = 0; - setTimeout(() => settle({ receiptId: 'settled-after-refusal', status: 'queued' }), 20); - await expect(runVerifiedNativeExtensionSandbox({ + const run = runVerifiedNativeExtensionSandbox({ artifact: await artifact(hostileImport([ capabilityFrame(), { type: 'result', completionReason: 'success', capabilityCalls: 1 }, ])), - manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, - babysitterTurn: { queue: async () => { calls += 1; return await adapter; } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, + babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, + }); + await waitForInvocation(invoked); + settle({ receiptId: 'settled-after-refusal', status: 'queued' }); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); expect(calls).toBe(1); - }); + }, 15_000); it('rejects non-object protocol output with zero adapter calls', async () => { let calls = 0; @@ -135,18 +146,25 @@ describe('hosted extension hostile protocol', () => { }); it('rejects two forged calls after the authoritative first outcome settles', async () => { + let settle!: (value: unknown) => void; + const adapter = new Promise(resolve => { settle = resolve; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); let calls = 0; - await expect(runVerifiedNativeExtensionSandbox({ + const run = runVerifiedNativeExtensionSandbox({ artifact: await artifact(hostileImport([capabilityFrame(), capabilityFrame()])), - manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, babysitterTurn: { queue: async () => { calls += 1; - await new Promise(resolve => setTimeout(resolve, 20)); - return { receiptId: 'settled', status: 'queued' }; + markInvoked(); + return await adapter; } }, - })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + }); + await waitForInvocation(invoked); + settle({ receiptId: 'settled', status: 'queued' }); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); expect(calls).toBe(1); - }); + }, 15_000); it.each(['reject', 'resolve'] as const)( 'waits for a pending adapter to %s after a forged child error', async outcome => { @@ -163,15 +181,14 @@ describe('hosted extension hostile protocol', () => { babysitterTurn: { queue: async () => { markInvoked(); return await adapter; } }, }).finally(() => { completed = true; }); const observed = run.then(() => undefined, error => error as Error); - await invoked; - await new Promise(resolve => setTimeout(resolve, 20)); + await waitForInvocation(invoked); expect(completed).toBe(false); if (outcome === 'reject') reject(refusal); else settle({ receiptId: 'settled', status: 'queued' }); const error = await observed; if (outcome === 'reject') expect(error).toBe(refusal); else expect(error).toMatchObject({ code: 'plugin_unsupported' }); - }, + }, 15_000, ); it('returns a typed adapter rejection even when the hostile child hangs', async () => { @@ -187,13 +204,7 @@ describe('hosted extension hostile protocol', () => { babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, }); const observed = run.then(() => undefined, error => error as Error); - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('hostile child did not invoke the adapter')), - 10_000, - ); - void invoked.then(() => { clearTimeout(timeout); resolve(); }, reject); - }); + await waitForInvocation(invoked); rejectAdapter(refusal); expect(await observed).toBe(refusal); expect(calls).toBe(1); From 9d29580b92e32f67c96acc8051b1c581fd863eb5 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 08:07:20 -0700 Subject: [PATCH 14/80] refactor(sdk): remove obsolete hosted base brand Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 16 ++++----- packages/sdk/src/authored-flow-loader.ts | 34 +------------------ 2 files changed, 9 insertions(+), 41 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 8c9e6fb91..83ff9b9c0 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -20,17 +20,17 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (3 tests) 19ms - ✓ tests/preflight.test.ts (59 tests) 102ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 386ms - ✓ tests/authored-flow.test.ts (34 tests) 752ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 798ms - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 1191ms + ✓ tests/hosted-base-snapshot.test.ts (3 tests) 20ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 413ms + ✓ tests/authored-flow.test.ts (34 tests) 732ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 786ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 1060ms Test Files 6 passed (6) Tests 159 passed (159) - Start at 08:05:03 - Duration 2.22s (transform 978ms, setup 0ms, collect 3.85s, tests 3.25s, environment 1ms, prepare 310ms) + Start at 08:06:54 + Duration 2.12s (transform 1.01s, setup 0ms, collect 4.04s, tests 3.11s, environment 1ms, prepare 347ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js diff --git a/packages/sdk/src/authored-flow-loader.ts b/packages/sdk/src/authored-flow-loader.ts index d17be8cae..5a7db01a4 100644 --- a/packages/sdk/src/authored-flow-loader.ts +++ b/packages/sdk/src/authored-flow-loader.ts @@ -12,9 +12,6 @@ import { import { canonicalize } from './canonical.js'; import { composeDefinition, loadFlowExtensions, parseHooksExport, type ImportedFlow, type LoadedFlowExtension } from './flow-extension-loader.js'; import type { RuntimeVersions } from './flow-extension-compat.js'; -import { PluginError } from './plugin-manifest.js'; - -const HOSTED_BASE_AUTHORITY = new WeakSet(); export class AuthoredFlowLoadError extends Error { constructor(message: string, readonly kind: 'invalid_spec' | 'use_not_found' | 'use_invalid' | 'use_cycle' = 'invalid_spec') { @@ -70,35 +67,6 @@ export interface LoadedAuthoredFlowNode { readonly use: readonly string[]; } -/** - * Project the actual base identity only from a flow this loader imported with - * extensions disabled. Structural lookalikes and composed flows are refused. - */ -export function hostedExtensionBaseFromLoadedFlow( - value: unknown, -): Readonly<{ name: string; version?: string }> { - if (typeof value !== 'object' || value === null || !HOSTED_BASE_AUTHORITY.has(value) - || !Array.isArray((value as Partial).extensions) - || (value as LoadedAuthoredFlow).extensions.length !== 0) { - throw new PluginError( - 'plugin_incompatible', - 'Hosted extension base must be the result of loadAuthoredFlow(..., { extensions: \'none\' }).', - ); - } - const loaded = value as LoadedAuthoredFlow; - const definition = loaded.getDefinition(loaded.handle); - return Object.freeze({ - name: definition.name, - ...(definition.header.version === undefined ? {} : { version: definition.header.version }), - }); -} - -function authorizedLoadedFlow(value: LoadedAuthoredFlow): LoadedAuthoredFlow { - const frozen = Object.freeze(value); - HOSTED_BASE_AUTHORITY.add(frozen); - return frozen; -} - /** Import and validate a direct-run module without executing its authored body. */ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOptions = {}): Promise { const loaded = new Map(); @@ -147,7 +115,7 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp const root = await visit(resolve(path), true); const graph = [...loaded.values()]; if (options.extensions === 'none') { - return authorizedLoadedFlow({ sourcePath: root.path, handle: root.handle, getDefinition: root.getDefinition, + return Object.freeze({ sourcePath: root.path, handle: root.handle, getDefinition: root.getDefinition, surfaceAuthority: root.surfaceAuthority, graph: Object.freeze(graph), extensions: Object.freeze([]) }); } // Extensions are verified (lock, digest, manifest hash, compat) before their From 80de6b35250325fa4b0074850507b672e874b164 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 08:28:22 -0700 Subject: [PATCH 15/80] fix(sdk): bound sandbox memory across Node releases Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 3 +- evidence/babysitter-gate8-isolation/README.md | 7 ++- .../verification.txt | 52 ++++++++++++++++--- packages/sdk/src/hosted-extension-sandbox.ts | 19 ++++--- .../tests/hosted-extension-isolation.test.ts | 26 ++++++++-- 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index 7537c4af9..e5c000f59 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -37,7 +37,8 @@ reviewed Software Factory source in the same `loadHostedExtensionRuntime` genera cross-extension route ambiguity, accepts only the exact published Babysitter ref/digest/manifest and native permission profile, imports the entry only inside Linux bubblewrap plus Node's -permission model under an inherited 1.5 GiB hard address-space limit, mounts a +permission model under inherited 16 GiB address-space and 3 GiB +data/anonymous-memory hard limits, mounts a minimal trusted Surface facade (`flow`, `github`, and `getFlowDefinition`) from six integrity-pinned private runtime files instead of the general helper runtime, checks diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 9543263de..c1ff5c697 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -29,8 +29,11 @@ The enforced boundary is: flow context; a generated facade mounts only `flow`, `github`, `getFlowDefinition`, and private copies of six SHA-256-pinned Surface runtime files, followed by one final runtime-generation check before launch; an - inherited 1.5 GiB hard address-space limit covers heap, Buffer/native memory, - mappings, and descendants in addition to the 64 MiB V8 old-space setting; + inherited hard limits of 16 GiB address space and 3 GiB data/anonymous + memory cover heap, Buffer/native memory, mappings, and descendants in + addition to the 64 MiB V8 old-space setting; the wider address-space ceiling + admits Node's virtual V8/Wasm reservations while the tighter data limit + refuses two hostile 2 GiB Buffers; - expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and `done`, validate the exact request and `{ receiptId, status }` response in the parent, and pass the original non-serializable authority to the host adapter; diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 83ff9b9c0..aba335e2a 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -20,17 +20,53 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (3 tests) 20ms - ✓ tests/preflight.test.ts (59 tests) 100ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 413ms - ✓ tests/authored-flow.test.ts (34 tests) 732ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 786ms - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 1060ms + ✓ tests/hosted-base-snapshot.test.ts (3 tests) 16ms + ✓ tests/preflight.test.ts (59 tests) 55ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 310ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 557ms + ✓ tests/authored-flow.test.ts (34 tests) 749ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 734ms Test Files 6 passed (6) Tests 159 passed (159) - Start at 08:06:54 - Duration 2.12s (transform 1.01s, setup 0ms, collect 4.04s, tests 3.11s, environment 1ms, prepare 347ms) + Start at 08:26:17 + Duration 1.77s (transform 933ms, setup 0ms, collect 3.92s, tests 2.42s, environment 1ms, prepare 325ms) + +$ mise exec node@22 -- node --version +v22.23.2 +$ mise exec node@22 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 775ms + + Test Files 1 passed (1) + Tests 15 passed (15) + +$ mise exec node@23 -- node --version +v23.11.1 +$ mise exec node@23 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 910ms + + Test Files 1 passed (1) + Tests 15 passed (15) + +$ mise exec node@24 -- node --version +v24.21.0 +$ mise exec node@24 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 681ms + + Test Files 1 passed (1) + Tests 15 passed (15) + +$ node --version +v26.8.1 +$ ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 737ms + + Test Files 1 passed (1) + Tests 15 passed (15) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index b34a9a7d8..154963e0f 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -16,11 +16,14 @@ import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const DEFAULT_TIMEOUT_MS = 10_000; -// RLIMIT_AS is inherited across prlimit -> bubblewrap -> Node and covers V8, -// Buffer/native allocations, mappings, and any descendants. 1.5 GiB leaves -// room for Node's reserved code ranges while deterministically refusing a -// hostile 256 MiB Buffer under the separately constrained 64 MiB old-space. -const ADDRESS_SPACE_BYTES = 1_536 * 1024 * 1024; +// These hard limits are inherited across prlimit -> bubblewrap -> Node and its +// descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 +// and Wasm reservations; RLIMIT_DATA is the tighter bound on anonymous/native +// allocations (including Buffer mmap on supported Linux kernels). Together +// with 64 MiB old-space they refuse two hostile 2 GiB Buffers without preventing +// the pinned TypeScript handler from starting on supported Node releases. +const ADDRESS_SPACE_BYTES = 16 * 1024 * 1024 * 1024; +const DATA_BYTES = 3 * 1024 * 1024 * 1024; const SURFACE_RUNTIME_SHA256 = Object.freeze({ 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', @@ -92,7 +95,11 @@ export async function runHostedExtensionSandbox( surfaceFacade, }); await options.beforeLaunch?.(); - const child = spawn(prlimit, [`--as=${ADDRESS_SPACE_BYTES}`, '--', bwrap, ...args], { + const child = spawn(prlimit, [ + `--as=${ADDRESS_SPACE_BYTES}`, + `--data=${DATA_BYTES}`, + '--', bwrap, ...args, + ], { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'], }); return await exchangeHostedExtension( diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 9b4927367..75e0f5290 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -8,6 +8,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; +import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -278,6 +279,13 @@ process.exit(child.status ?? 1); }); it('denies ambient credentials, host files, writes, network, subprocesses, and undeclared context verbs', async () => { + const server = createServer((_request, response) => response.end('host-network-visible')); + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('host network fixture did not listen'); const sentinel = join(mkdtempSync(join(tmpdir(), 'hosted-sentinel-')), 'secret.txt'); roots.push(sentinel.slice(0, sentinel.lastIndexOf('/'))); writeFileSync(sentinel, 'host-secret'); @@ -286,13 +294,20 @@ process.exit(child.status ?? 1); import { flow, github } from '@relayflows/surface'; import { readFileSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; +import { connect } from 'node:net'; const denied = async fn => { try { await fn(); return 'allowed'; } catch (error) { return error?.code ?? error?.message ?? 'denied'; } }; +const reachHostNetwork = () => new Promise((resolveNetwork, rejectNetwork) => { + const socket = connect({ host: '127.0.0.1', port: ${address.port} }); + const timer = setTimeout(() => { socket.destroy(); rejectNetwork(new Error('network timeout')); }, 1000); + socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolveNetwork(); }); + socket.once('error', error => { clearTimeout(timer); rejectNetwork(error); }); +}); const evidence = { ambient: process.env.HOSTED_EXTENSION_TEST_SECRET, read: await denied(() => readFileSync(${JSON.stringify(sentinel)}, 'utf8')), write: await denied(() => writeFileSync(${JSON.stringify(escaped)}, 'escape')), child: await denied(() => { const result = spawnSync('/usr/bin/true'); if (result.error) throw result.error; }), - network: await denied(() => fetch('https://example.com')), + network: await denied(reachHostNetwork), }; export default flow('babysitter', async f => f.done('declined')) .on(github.pull_request('labeled'), async (f, input) => { @@ -326,16 +341,17 @@ export default flow('babysitter', async f => f.done('declined')) babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, }); } finally { + server.close(); if (original === undefined) delete process.env.HOSTED_EXTENSION_TEST_SECRET; else process.env.HOSTED_EXTENSION_TEST_SECRET = original; } expect(() => readFileSync(escaped)).toThrow(); }); - it('enforces an OS address-space bound on native Buffer allocation', async () => { + it('enforces OS address-space and data bounds on native Buffer allocation', async () => { const installed = await artifact(` - const allocation = Buffer.alloc(256 * 1024 * 1024, 1); - if (allocation.byteLength !== 256 * 1024 * 1024) throw new Error('short allocation'); + const allocations = [Buffer.alloc(2 * 1024 * 1024 * 1024), Buffer.alloc(2 * 1024 * 1024 * 1024)]; + if (allocations.some(allocation => allocation.byteLength !== 2 * 1024 * 1024 * 1024)) throw new Error('short allocation'); ${ordinaryExtension} `); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ @@ -353,7 +369,7 @@ export default flow('babysitter', async f => f.done('declined')) } }, })).rejects.toMatchObject({ code: 'plugin_unsupported', - message: expect.stringContaining('Failed to allocate memory'), + message: expect.stringMatching(/(?:Failed to allocate memory|Array buffer allocation failed)/u), }); expect(calls).toBe(0); }); From 672365c432485987a206bbac403a41525bc7e0ac Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 08:42:51 -0700 Subject: [PATCH 16/80] fix(sdk): bound hosted source traversal Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/BABYSITTER-CATALOG-HANDOFF.md | 5 +- evidence/babysitter-gate8-isolation/README.md | 5 +- .../verification.txt | 40 +++--- packages/sdk/src/hosted-base-snapshot.ts | 116 +++++++++++++----- .../sdk/tests/hosted-base-snapshot.test.ts | 60 ++++++++- .../tests/hosted-extension-isolation.test.ts | 42 ------- .../tests/hosted-extension-routing.test.ts | 47 +++++++ packages/sdk/tsconfig.tests.json | 1 + 8 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 packages/sdk/tests/hosted-extension-routing.test.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index e5c000f59..ad1381c8c 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -64,8 +64,9 @@ Before replacing #549's refusal, the hosted caller must obtain an opaque base and installation as one generation with `loadHostedExtensionRuntime`, then call `runHostedCapabilityExtension` with both values. Every dispatch rechecks the current extension declarations and complete project source tree against that -generation. File-count and cumulative-byte bounds are enforced from no-follow -file metadata before source contents are buffered. The loader never imports +generation. Directory entries are streamed beneath a shared entry bound; +nonblocking no-follow descriptors and explicitly bounded reads enforce the +cumulative-byte limit before source contents are buffered. The loader never imports tenant base code to derive authority. It requires the exact reviewed Software Factory flow-file SHA-256 and assigns its pinned name/version in the parent; project `node_modules`, relative imports, diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index c1ff5c697..3a497b0b8 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -14,8 +14,9 @@ The enforced boundary is: every dispatch rechecks the current declarations plus complete project source; base stdout, globals, process termination, relative/package imports, and an earlier module cache cannot forge the parent-pinned identity; source - file-count and byte limits are enforced from no-follow file metadata before - contents are buffered; + directory entries are streamed beneath a shared entry bound; nonblocking + no-follow descriptors and explicitly bounded reads enforce the byte limit + before contents are buffered; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index aba335e2a..12ecd9db9 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -7,7 +7,7 @@ bubblewrap 0.12.0 prlimit from util-linux 2.42.3 v26.8.1 -$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-base-snapshot.test.ts tests/hosted-extension-isolation.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-base-snapshot.test.ts tests/hosted-extension-isolation.test.ts tests/hosted-extension-routing.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts > @relayflows/sdk@2.0.26 typecheck > tsc --noEmit && tsc -p tsconfig.type-tests.json @@ -20,53 +20,55 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (3 tests) 16ms - ✓ tests/preflight.test.ts (59 tests) 55ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 310ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 557ms - ✓ tests/authored-flow.test.ts (34 tests) 749ms - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 734ms + ✓ tests/hosted-base-snapshot.test.ts (6 tests) 604ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 577ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 59ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 393ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 559ms + ✓ tests/authored-flow.test.ts (34 tests) 740ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 742ms - Test Files 6 passed (6) - Tests 159 passed (159) - Start at 08:26:17 - Duration 1.77s (transform 933ms, setup 0ms, collect 3.92s, tests 2.42s, environment 1ms, prepare 325ms) + Test Files 7 passed (7) + Tests 162 passed (162) + Start at 08:41:19 + Duration 1.87s (transform 1.04s, setup 0ms, collect 5.24s, tests 3.10s, environment 1ms, prepare 432ms) $ mise exec node@22 -- node --version v22.23.2 $ mise exec node@22 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 775ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 788ms Test Files 1 passed (1) - Tests 15 passed (15) + Tests 13 passed (13) $ mise exec node@23 -- node --version v23.11.1 $ mise exec node@23 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 910ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 953ms Test Files 1 passed (1) - Tests 15 passed (15) + Tests 13 passed (13) $ mise exec node@24 -- node --version v24.21.0 $ mise exec node@24 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 681ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 694ms Test Files 1 passed (1) - Tests 15 passed (15) + Tests 13 passed (13) $ node --version v26.8.1 $ ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts - ✓ tests/hosted-extension-isolation.test.ts (15 tests) 737ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 632ms Test Files 1 passed (1) - Tests 15 passed (15) + Tests 13 passed (13) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 3e286806d..adc49a096 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; -import { chmod, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; @@ -8,8 +8,10 @@ import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); -const MAX_FILES = 10_000; +const MAX_ENTRIES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; +const MAX_DEPTH = 64; +const READ_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; interface SourceFile { readonly path: string; @@ -18,10 +20,16 @@ interface SourceFile { } interface SourceBudget { - files: number; + entries: number; bytes: number; } +/** @internal Deterministic race seams for this security boundary's tests. */ +export interface HostedBaseSnapshotTestHooks { + readonly beforeOpen?: (path: string) => Promise; + readonly afterStat?: (path: string) => Promise; +} + export interface HostedBaseSourceRoot { readonly root: string; readonly prefix: string; @@ -80,18 +88,22 @@ export async function createHostedBaseSnapshot(flowPath: string): Promise { - return sourceDigest(await readAuthorityFiles(sources)); + return sourceDigest(await readAuthorityFiles(sources, hooks)); } export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Promise { await rm(snapshot.snapshotRoot, { recursive: true, force: true }); } -async function readAuthorityFiles(sources: readonly HostedBaseSourceRoot[]): Promise { - const budget: SourceBudget = { files: 0, bytes: 0 }; +async function readAuthorityFiles( + sources: readonly HostedBaseSourceRoot[], + hooks: HostedBaseSnapshotTestHooks = {}, +): Promise { + const budget: SourceBudget = { entries: 0, bytes: 0 }; const files: SourceFile[] = []; - for (const source of sources) files.push(...await readTree(source.root, source.prefix, budget)); + for (const source of sources) files.push(...await readTree(source.root, source.prefix, budget, hooks)); files.sort((left, right) => left.path.localeCompare(right.path)); return Object.freeze(files); } @@ -120,52 +132,94 @@ async function readTree( root: string, prefix: string, budget: SourceBudget, + hooks: HostedBaseSnapshotTestHooks, ): Promise { + if (process.platform !== 'linux') { + throw invalid('Hosted base source snapshotting requires Linux.'); + } const files: SourceFile[] = []; - async function visit(directory: string, relativeDirectory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { + async function visit( + directory: Awaited>, + relativeDirectory: string, + depth: number, + ): Promise { + if (depth > MAX_DEPTH) throw tooLarge(); + const entries = await opendir(`/proc/self/fd/${directory.fd}`); + for await (const entry of entries) { if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; + budget.entries += 1; + if (budget.entries > MAX_ENTRIES) throw tooLarge(); const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); - const absolutePath = join(directory, entry.name); - if (entry.isDirectory()) await visit(absolutePath, relativePath); - else if (entry.isFile()) { - budget.files += 1; - if (budget.files > MAX_FILES) throw tooLarge(); - const handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const before = await handle.stat({ bigint: true }); - if (!before.isFile() || before.size < 0n - || before.size > BigInt(MAX_BYTES - budget.bytes)) throw tooLarge(); - budget.bytes += Number(before.size); - const bytes = await handle.readFile(); + const absolutePath = join(root, relativePath); + await hooks.beforeOpen?.(absolutePath); + const handle = await open(`/proc/self/fd/${directory.fd}/${entry.name}`, READ_FLAGS); + try { + const before = await handle.stat({ bigint: true }); + if (before.isDirectory()) { + await visit(handle, relativePath, depth + 1); + } else if (before.isFile()) { + if (before.size < 0n || before.size > BigInt(MAX_BYTES - budget.bytes)) throw tooLarge(); + await hooks.afterStat?.(absolutePath); + const bytes = await readBounded(handle, Number(before.size), relativePath); const after = await handle.stat({ bigint: true }); - if (bytes.byteLength !== Number(before.size) || after.size !== before.size - || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs + || after.ctimeNs !== before.ctimeNs) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } + budget.bytes += bytes.byteLength; files.push(Object.freeze({ path: prefix === '' ? relativePath : join(prefix, relativePath), bytes, sha256: sha256(bytes), })); - } finally { - await handle.close(); + } else { + throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); } - } else throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); + } finally { + await handle.close(); + } } } - try { await visit(root, ''); } - catch (error) { + try { + const rootHandle = await open(root, READ_FLAGS); + try { + if (!(await rootHandle.stat()).isDirectory()) { + throw invalid('Hosted base source root is not a directory.'); + } + await visit(rootHandle, '', 0); + } finally { + await rootHandle.close(); + } + } catch (error) { if (error instanceof PluginError) throw error; throw invalid('Hosted base source or trusted dependency is unreadable.'); } return Object.freeze(files); } +async function readBounded( + handle: Awaited>, + expectedBytes: number, + relativePath: string, +): Promise { + const bytes = Buffer.allocUnsafe(expectedBytes); + let offset = 0; + while (offset < expectedBytes) { + const result = await handle.read(bytes, offset, expectedBytes - offset, offset); + if (result.bytesRead === 0) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + offset += result.bytesRead; + } + const extra = Buffer.allocUnsafe(1); + if ((await handle.read(extra, 0, 1, expectedBytes)).bytesRead !== 0) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + return bytes; +} + function tooLarge(): PluginError { - return invalid('Hosted base source exceeds the snapshot file or byte limit.'); + return invalid('Hosted base source exceeds the snapshot entry or byte limit.'); } function sourceDigest(files: readonly SourceFile[]): string { diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index b87ee4fbb..4bfc80703 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -60,7 +61,62 @@ describe('hosted base private snapshot', () => { truncateSync(oversized, 64 * 1024 * 1024 + 1); await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ code: 'plugin_source_invalid', - message: expect.stringContaining('snapshot file or byte limit'), + message: expect.stringContaining('snapshot entry or byte limit'), + }); + }); + + it('bounds a file that grows after its admitted size was checked', async () => { + const { project } = fixture(); + const raced = join(project, 'raced.bin'); + writeFileSync(raced, 'small'); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }], { + afterStat: async path => { + if (path === raced) truncateSync(raced, 64 * 1024 * 1024 + 1); + }, + })).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('changed while reading "raced.bin"'), + }); + }); + + it.runIf(process.platform === 'linux' && existsSync('/usr/bin/mkfifo'))( + 'opens a substituted FIFO without blocking before rejecting it', + async () => { + const { project } = fixture(); + const raced = join(project, 'raced.txt'); + writeFileSync(raced, 'regular'); + let release: ReturnType | undefined; + const started = Date.now(); + try { + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }], { + beforeOpen: async path => { + if (path !== raced) return; + rmSync(raced); + const result = spawnSync('/usr/bin/mkfifo', [raced]); + if (result.status !== 0) throw new Error(result.stderr.toString()); + // If O_NONBLOCK is removed, release the read-only open so the test + // fails on elapsed time instead of hanging the test process. + release = setTimeout(() => writeFileSync(raced, 'release'), 1_000); + }, + })).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('unsupported entry "raced.txt"'), + }); + expect(Date.now() - started).toBeLessThan(500); + } finally { + if (release !== undefined) clearTimeout(release); + } + }, + ); + + it('stops streaming project entries at the shared count limit', async () => { + const { project, flowPath } = fixture(); + for (let index = 0; index < 10_001; index += 1) { + writeFileSync(join(project, `empty-${index}.txt`), ''); + } + await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('snapshot entry or byte limit'), }); }); }); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 75e0f5290..60ff82b08 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -12,17 +12,13 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { github } from '@relayflows/surface'; -import { extensionHandlerForHostedDispatch } from '../src/flow-extension-loader.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { - hostedManifestRoutes, loadHostedExtensionArtifacts, runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; -import { supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; import { materializePlugin } from '../src/plugin-store.js'; const roots: string[] = []; @@ -467,42 +463,4 @@ export default flow('babysitter', async f => f.done('declined')) } }); - it('accepts only Node releases that implement every sandbox flag', () => { - expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); - expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); - expect(supportsHostedSandboxFlags('23.4.0')).toBe(false); - expect(supportsHostedSandboxFlags('23.5.0')).toBe(true); - expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); - expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); - }); - - it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { - const body = async () => {}; - const specific = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; - const duplicate = { name: 'duplicate', handlers: [{ trigger: github.pull_request('labeled'), body }] }; - const generic = { name: 'generic', handlers: [{ trigger: github.pull_request(), body }] }; - const labeled = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }); - const edited = hostedExtensionDispatchFromVerifiedDelivery({ - provider: 'github', eventType: 'pull_request.edited', deliveryId: 'delivery-2', - }); - expect(extensionHandlerForHostedDispatch(labeled, [specific])?.extension.name).toBe('specific'); - expect(hostedManifestRoutes( - { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, - { provider: 'github', event: 'pull_request', action: 'labeled' }, - )).toBe(true); - expect(extensionHandlerForHostedDispatch(edited, [specific])).toBeUndefined(); - expect(hostedManifestRoutes( - { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, - { provider: 'github', event: 'pull_request', action: 'edited' }, - )).toBe(false); - expect(() => extensionHandlerForHostedDispatch(labeled, [specific, duplicate])) - .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); - expect(() => extensionHandlerForHostedDispatch(labeled, [specific, generic])) - .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); - expect(() => extensionHandlerForHostedDispatch({ - provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', - }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); - }); }); diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts new file mode 100644 index 000000000..d0aa40246 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { github } from '@relayflows/surface'; +import { extensionHandlerForHostedDispatch } from '../src/flow-extension-loader.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; +import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; +import { supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; + +describe('hosted extension routing policy', () => { + it('accepts only Node releases that implement every sandbox flag', () => { + expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); + expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); + expect(supportsHostedSandboxFlags('23.4.0')).toBe(false); + expect(supportsHostedSandboxFlags('23.5.0')).toBe(true); + expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); + expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); + }); + + it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { + const body = async () => {}; + const specific = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const duplicate = { name: 'duplicate', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const generic = { name: 'generic', handlers: [{ trigger: github.pull_request(), body }] }; + const labeled = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const edited = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.edited', deliveryId: 'delivery-2', + }); + expect(extensionHandlerForHostedDispatch(labeled, [specific])?.extension.name).toBe('specific'); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + )).toBe(true); + expect(extensionHandlerForHostedDispatch(edited, [specific])).toBeUndefined(); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'edited' }, + )).toBe(false); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, duplicate])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, generic])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch({ + provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index b85d6d180..157587d07 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -27,6 +27,7 @@ "tests/authored-flow.test.ts", "tests/authored-agent-permissions.test.ts", "tests/hosted-extension-isolation.test.ts", + "tests/hosted-extension-routing.test.ts", "tests/hosted-base-snapshot.test.ts", "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", From d5b561d17c40d831417ca6a8782113a3ef840713 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 08:47:58 -0700 Subject: [PATCH 17/80] docs(evidence): record complete Node matrix output Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../babysitter-gate8-isolation/verification.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 12ecd9db9..958b05cf7 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -38,37 +38,53 @@ $ mise exec node@22 -- node --version v22.23.2 $ mise exec node@22 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 788ms Test Files 1 passed (1) Tests 13 passed (13) + Start at 08:41:32 + Duration 2.38s (transform 879ms, setup 0ms, collect 1.30s, tests 788ms, environment 0ms, prepare 103ms) $ mise exec node@23 -- node --version v23.11.1 $ mise exec node@23 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 953ms Test Files 1 passed (1) Tests 13 passed (13) + Start at 08:41:32 + Duration 2.56s (transform 661ms, setup 0ms, collect 1.16s, tests 953ms, environment 0ms, prepare 121ms) $ mise exec node@24 -- node --version v24.21.0 $ mise exec node@24 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 694ms Test Files 1 passed (1) Tests 13 passed (13) + Start at 08:41:32 + Duration 2.36s (transform 762ms, setup 0ms, collect 1.27s, tests 694ms, environment 0ms, prepare 76ms) $ node --version v26.8.1 $ ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 632ms Test Files 1 passed (1) Tests 13 passed (13) + Start at 08:41:35 + Duration 1.91s (transform 728ms, setup 0ms, collect 1.12s, tests 632ms, environment 0ms, prepare 59ms) $ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js From 5ae3ef201d83fd110fefff5560ab1fef8a00cebe Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 09:08:51 -0700 Subject: [PATCH 18/80] fix(sdk): bound plugin store verification reads Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- evidence/babysitter-gate8-isolation/README.md | 4 +- .../verification.txt | 29 ++-- packages/sdk/src/bundle-extensions.ts | 11 +- packages/sdk/src/flow-extension-loader.ts | 8 +- .../sdk/src/hosted-extension-isolation.ts | 18 +- packages/sdk/src/plugin-store.ts | 156 +++++++++++++++--- .../sdk/tests/plugin-store-bounds.test.ts | 53 ++++++ packages/sdk/tsconfig.tests.json | 1 + 8 files changed, 223 insertions(+), 57 deletions(-) create mode 100644 packages/sdk/tests/plugin-store-bounds.test.ts diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 3a497b0b8..8b83f0e7e 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -18,7 +18,9 @@ The enforced boundary is: no-follow descriptors and explicitly bounded reads enforce the byte limit before contents are buffered; - reverify every content-addressed artifact, lock metadata, manifest hash, - base/runtime compatibility, and route uniqueness, then require the exact + base/runtime compatibility, and route uniqueness using bounded descriptor + reads that enforce the fetched plugin's 500-file, 256-KB-per-file, and 2-MB + total limits before allocation, then require the exact immutable native ref/digest/manifest and narrow permission profile; copy the selected bytes into a private snapshot, recompute its digest, and mount only that snapshot so later store replacement cannot change executed code; diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 958b05cf7..b332c1529 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -7,7 +7,7 @@ bubblewrap 0.12.0 prlimit from util-linux 2.42.3 v26.8.1 -$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/hosted-base-snapshot.test.ts tests/hosted-extension-isolation.test.ts tests/hosted-extension-routing.test.ts tests/hosted-extension-protocol.test.ts tests/babysitter-native-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts > @relayflows/sdk@2.0.26 typecheck > tsc --noEmit && tsc -p tsconfig.type-tests.json @@ -20,19 +20,20 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/hosted-base-snapshot.test.ts (6 tests) 604ms - ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 577ms - ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms - ✓ tests/preflight.test.ts (59 tests) 59ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 393ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 559ms - ✓ tests/authored-flow.test.ts (34 tests) 740ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 742ms - - Test Files 7 passed (7) - Tests 162 passed (162) - Start at 08:41:19 - Duration 1.87s (transform 1.04s, setup 0ms, collect 5.24s, tests 3.10s, environment 1ms, prepare 432ms) + ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms + ✓ tests/hosted-base-snapshot.test.ts (6 tests) 371ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 354ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 3ms + ✓ tests/preflight.test.ts (59 tests) 97ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 348ms + ✓ tests/hosted-extension-protocol.test.ts (10 tests) 536ms + ✓ tests/authored-flow.test.ts (34 tests) 743ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 753ms + + Test Files 8 passed (8) + Tests 165 passed (165) + Start at 09:08:10 + Duration 1.95s (transform 1.05s, setup 0ms, collect 5.73s, tests 2.86s, environment 1ms, prepare 483ms) $ mise exec node@22 -- node --version v22.23.2 diff --git a/packages/sdk/src/bundle-extensions.ts b/packages/sdk/src/bundle-extensions.ts index d008141fc..15340173a 100644 --- a/packages/sdk/src/bundle-extensions.ts +++ b/packages/sdk/src/bundle-extensions.ts @@ -5,7 +5,7 @@ import { safePath, sha256 } from './bundle.js'; import { findPluginProject } from './plugin-loader.js'; import { parsePluginLock, readPluginLock, reconcileDeclaredExtensions, type PluginLock } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; -import { pluginStoreDirectory, readStoredPluginFiles, verifyStoredPlugin } from './plugin-store.js'; +import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; const EMPTY_LOCK: PluginLock = Object.freeze({ version: 2, plugins: Object.freeze([]) }); @@ -50,10 +50,11 @@ export async function verifyBundlePluginLock(bundle: string): Promise { throw new Error(`lockfile.json: plugin name ${entry.name} is not a safe path component`); } const directory = join(bundle, 'plugins', entry.name); - await verifyStoredPlugin(directory, entry.digest); - let pluginManifest: Buffer; - try { pluginManifest = await readFile(join(directory, 'flows-plugin.json')); } - catch { throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); } + const stored = await readStoredPluginFiles(directory, entry.digest); + const pluginManifest = stored.find(file => file.path === 'flows-plugin.json')?.data; + if (pluginManifest === undefined) { + throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); + } if (sha256(pluginManifest) !== entry.manifestSha256) { throw new Error(`lockfile.json: plugin ${entry.name} flows-plugin.json does not match the lockfile manifest hash`); } diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 32467af24..0969fa4d2 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { Ctx } from '@relayflows/surface'; import type { AuthoredFlowDefinition, FlowHandle } from './authored-flow.js'; @@ -8,7 +7,7 @@ import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flo import { findPluginProject } from './plugin-loader.js'; import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; -import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; +import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; /** * Compose schema-2 flow extensions onto a base authored flow. @@ -211,8 +210,9 @@ async function loadOne( options: LoadFlowExtensionsOptions, ): Promise { const directory = pluginStoreDirectory(root, lock.name, lock.digest); - await verifyStoredPlugin(directory, lock.digest); - const manifestBytes = readFileSync(join(directory, 'flows-plugin.json')); + const stored = await readStoredPluginFiles(directory, lock.digest); + const manifestBytes = stored.find(file => file.path === 'flows-plugin.json')?.data; + if (manifestBytes === undefined) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json is missing.`); if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`); let input: unknown; try { input = JSON.parse(manifestBytes.toString('utf8')); } diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 919b236f9..aa48f9493 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,6 +1,4 @@ import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { canonicalize } from './canonical.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; @@ -16,7 +14,7 @@ import { type HostedExtensionInstallation, } from './hosted-extension-runtime.js'; import { PluginError } from './plugin-manifest.js'; -import { verifyStoredPlugin } from './plugin-store.js'; +import { readStoredPluginFiles } from './plugin-store.js'; import { boundedJsonSnapshot, type HostedExtensionProtocolResult, @@ -205,8 +203,11 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise file.path === 'flows-plugin.json')?.data; + if (bytes === undefined) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json is missing.`); + } if (sha256(bytes) !== artifact.manifestSha256) { throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json differs from the lockfile's manifest hash.`); } @@ -231,11 +232,8 @@ async function assertPinnedBabysitter(artifact: HostedExtensionArtifact): Promis 'Hosted capability isolation accepts only the reviewed native Babysitter artifact.', ); } - const payload = JSON.parse( - (await readFile(join(artifact.directory, 'manifest.json'))).toString('utf8'), - ) as Array<{ path?: unknown }>; - if (payload.some(file => typeof file.path === 'string' - && (file.path === 'node_modules' || file.path.startsWith('node_modules/')))) { + const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); + if (stored.some(file => file.path === 'node_modules' || file.path.startsWith('node_modules/'))) { throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); } } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 8ff7d232f..997fbe6c8 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -1,6 +1,8 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { lstat, mkdir, mkdtemp, open, opendir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { payloadManifest, safePath, sha256 } from './bundle.js'; +import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; /** @@ -13,6 +15,11 @@ import { PluginError } from './plugin-manifest.js'; * refusal, not a surprise. */ export const PLUGIN_STORE = '.flows/plugins'; +const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; +const MAX_PLUGIN_STORE_ENTRIES = 10_000; +const READ_FLAGS = constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_NONBLOCK ?? 0); export function pluginStoreDirectory(root: string, name: string, digest: string): string { return join(resolve(root), PLUGIN_STORE, `${name}@sha256:${digest}`); @@ -20,6 +27,12 @@ export function pluginStoreDirectory(root: string, name: string, digest: string) export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array } +/** @internal Deterministic race seams for the bounded store reader. */ +export interface StoredPluginReadTestHooks { + readonly beforeOpen?: (path: string) => Promise; + readonly afterStat?: (path: string) => Promise; +} + /** Write the files atomically; an existing directory is verified instead of overwritten. */ export async function materializePlugin(root: string, name: string, files: readonly StoredPluginFile[]): Promise<{ directory: string; digest: string }> { const manifest = payloadManifest(files); @@ -48,47 +61,137 @@ export async function materializePlugin(root: string, name: string, files: reado return { directory, digest }; } -async function regularFile(root: string, path: string): Promise { +async function regularFile( + root: string, + path: string, + maxBytes: number, + expectedBytes?: number, + hooks: StoredPluginReadTestHooks = {}, +): Promise { + const absolute = join(root, path); + const handle = await openStoredFile(root, path, hooks); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size < 0n || before.size > BigInt(maxBytes) + || (expectedBytes !== undefined && before.size !== BigInt(expectedBytes))) { + throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); + } + await hooks.afterStat?.(absolute); + const size = Number(before.size); + const bytes = Buffer.allocUnsafe(size); + let offset = 0; + while (offset < size) { + const result = await handle.read(bytes, offset, size - offset, offset); + if (result.bytesRead === 0) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + offset += result.bytesRead; + } + const extra = Buffer.allocUnsafe(1); + if ((await handle.read(extra, 0, 1, size)).bytesRead !== 0) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + const after = await handle.stat({ bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs + || after.ctimeNs !== before.ctimeNs) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function openStoredFile( + root: string, + path: string, + hooks: StoredPluginReadTestHooks, +): Promise>> { const parts = path.split('/'); - for (let i = 1; i <= parts.length; i++) { - const stat = await lstat(join(root, ...parts.slice(0, i))); - if (i === parts.length ? !stat.isFile() : !stat.isDirectory()) throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + if (process.platform !== 'linux') { + for (let i = 1; i < parts.length; i++) { + if (!(await lstat(join(root, ...parts.slice(0, i)))).isDirectory()) { + throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + } + } + await hooks.beforeOpen?.(join(root, path)); + return await open(join(root, path), READ_FLAGS); + } + let directory = await open(resolve(root), READ_FLAGS); + try { + if (!(await directory.stat()).isDirectory()) { + throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`); + } + for (const part of parts.slice(0, -1)) { + const child = await open(`/proc/self/fd/${directory.fd}/${part}`, READ_FLAGS); + if (!(await child.stat()).isDirectory()) { + await child.close(); + throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + } + await directory.close(); + directory = child; + } + await hooks.beforeOpen?.(join(root, path)); + return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)!}`, READ_FLAGS); + } finally { + await directory.close(); } - return readFile(join(root, path)); } /** Re-hash a materialized plugin and compare with the digest the lockfile recorded. */ -export async function verifyStoredPlugin(directory: string, expectedDigest: string): Promise { +export async function verifyStoredPlugin( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks = {}, +): Promise { + await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); +} + +async function readVerifiedStoredPluginFiles( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks, +): Promise { const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); }; - let raw: string; + let manifest: Buffer; try { if (!(await lstat(directory)).isDirectory()) return drift('not a directory'); - raw = (await regularFile(directory, 'manifest.json')).toString('utf8'); + manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); } + const raw = manifest.toString('utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); let entries: { path: string; sha256: string; bytes: number }[]; try { entries = JSON.parse(raw); if (!Array.isArray(entries)) throw new Error(); } catch { return drift('manifest.json is not a manifest'); } + if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`); const paths = new Set(); + const files = [{ path: 'manifest.json', data: manifest }]; + let totalBytes = 0; for (const entry of entries) { - if (typeof entry?.path !== 'string' || !safePath(entry.path)) return drift('manifest.json lists an invalid path'); + if (typeof entry?.path !== 'string' || !safePath(entry.path) + || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256) + || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_PLUGIN_FILE_BYTES + || paths.has(entry.path)) return drift('manifest.json lists an invalid file'); + totalBytes += entry.bytes; + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`); let data: Buffer; - try { data = await regularFile(directory, entry.path); } + try { data = await regularFile(directory, entry.path, MAX_PLUGIN_FILE_BYTES, entry.bytes, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); } - if (data.length !== entry.bytes || sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); + if (sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); paths.add(entry.path); + files.push({ path: entry.path, data }); } await rejectExtras(directory, '', new Set([...paths, 'manifest.json']), drift); + return Object.freeze(files.map(file => Object.freeze(file))); } /** Re-verify, then return every stored file including the payload `manifest.json`. */ -export async function readStoredPluginFiles(directory: string, expectedDigest: string): Promise { - await verifyStoredPlugin(directory, expectedDigest); - const manifest = await regularFile(directory, 'manifest.json'); - const entries = JSON.parse(manifest.toString('utf8')) as { path: string }[]; - const files = [{ path: 'manifest.json', data: manifest }]; - for (const entry of entries) files.push({ path: entry.path, data: await regularFile(directory, entry.path) }); - return files; +export async function readStoredPluginFiles( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks = {}, +): Promise { + return await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); } /** Drop a materialized plugin directory. Missing is a no-op. */ @@ -97,9 +200,16 @@ export async function removeStoredPlugin(directory: string): Promise { } async function rejectExtras(root: string, prefix: string, paths: Set, drift: (m: string) => never): Promise { - for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { - const path = prefix + entry.name; - if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await rejectExtras(root, `${path}/`, paths, drift); - else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`); + let entries = 0; + async function visit(currentPrefix: string): Promise { + const directory = await opendir(join(root, currentPrefix)); + for await (const entry of directory) { + entries += 1; + if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries'); + const path = currentPrefix + entry.name; + if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await visit(`${path}/`); + else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`); + } } + await visit(prefix); } diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts new file mode 100644 index 000000000..4659d3169 --- /dev/null +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { materializePlugin, readStoredPluginFiles, verifyStoredPlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'plugin-store-bounds-')); + roots.push(root); + const stored = await materializePlugin(root, 'bounded', [ + { path: 'flows-plugin.json', data: Buffer.from('{"name":"bounded"}') }, + { path: 'entry.ts', data: Buffer.from('export default {};') }, + ]); + return stored; +} + +describe('bounded plugin-store verification', () => { + it('refuses an oversized sparse payload before buffering it', async () => { + const stored = await fixture(); + truncateSync(join(stored.directory, 'entry.ts'), 256_001); + await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('expected a bounded regular file'), + }); + }); + + it('refuses an oversized sparse manifest before hashing it', async () => { + const stored = await fixture(); + truncateSync(join(stored.directory, 'manifest.json'), 4_000_001); + await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('expected a bounded regular file'), + }); + }); + + it('bounds a payload that grows after its admitted size was checked', async () => { + const stored = await fixture(); + const raced = join(stored.directory, 'entry.ts'); + await expect(readStoredPluginFiles(stored.directory, stored.digest, { + afterStat: async path => { + if (path === raced) { + writeFileSync(raced, Buffer.alloc(256_001)); + } + }, + })).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('changed while reading'), + }); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 157587d07..8bbe1b491 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -29,6 +29,7 @@ "tests/hosted-extension-isolation.test.ts", "tests/hosted-extension-routing.test.ts", "tests/hosted-base-snapshot.test.ts", + "tests/plugin-store-bounds.test.ts", "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", From 7e3781ae24ba89047539e6183a71050fba062d39 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 09:30:17 -0700 Subject: [PATCH 19/80] fix(sdk): validate snapshotted delivery data Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- evidence/babysitter-gate8-isolation/README.md | 14 ++-- .../verification.txt | 22 ++--- packages/sdk/src/canonical.ts | 12 ++- packages/sdk/src/flow-extension-compat.ts | 4 +- packages/sdk/src/flow-extension-loader.ts | 4 +- .../sdk/src/hosted-extension-isolation.ts | 60 ++++++++------ packages/sdk/src/hosted-extension-protocol.ts | 27 ++++--- packages/sdk/src/hosted-extension-sandbox.ts | 8 +- packages/sdk/src/json-value.ts | 34 +++++--- packages/sdk/src/plugin-store.ts | 3 +- .../tests/hosted-extension-protocol.test.ts | 81 +++++++++++++++++++ 11 files changed, 199 insertions(+), 70 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 8b83f0e7e..e7d6876d8 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -24,8 +24,11 @@ The enforced boundary is: immutable native ref/digest/manifest and narrow permission profile; copy the selected bytes into a private snapshot, recompute its digest, and mount only that snapshot so later store replacement cannot change executed code; -- validate a symbol-branded verified dispatch and a closed normalized delivery - descriptor before import; +- clone the delivery descriptor into behavior-free, frozen JSON with captured + parent intrinsics before validation or serialization, rejecting proxies, + accessors, inherited `toJSON`, symbol keys, cycles, holes, and extra fields; + then validate that snapshot against the symbol-branded verified dispatch + before import; - import and execute the matching handler only inside a bubblewrap namespace plus Node's permission model, with no network, writable filesystem, inherited environment, child process, workspace mount, MCP, helpers, harnesses, or base @@ -40,9 +43,10 @@ The enforced boundary is: - expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and `done`, validate the exact request and `{ receiptId, status }` response in the parent, and pass the original non-serializable authority to the host adapter; -- treat descriptor 3 as hostile transport: validate every frame and both - boundary payloads, so direct writes can consume only the same exact one-shot - delivery capability already granted and cannot claim premature success; +- treat descriptor 3 as hostile transport: clone and validate every frame and + both boundary payloads with captured parent intrinsics, so direct writes can + consume only the same exact one-shot delivery capability already granted and + cannot claim premature success; - fail closed on unknown frames, repeated/omitted calls, premature completion, timeouts, adapter rejection, incompatible bytes, route ambiguity, forged loader results, an unverified/composed base, and broader permissions. diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index b332c1529..fc8cc21e1 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -21,19 +21,19 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms - ✓ tests/hosted-base-snapshot.test.ts (6 tests) 371ms - ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 354ms - ✓ tests/hosted-extension-routing.test.ts (2 tests) 3ms - ✓ tests/preflight.test.ts (59 tests) 97ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 348ms - ✓ tests/hosted-extension-protocol.test.ts (10 tests) 536ms - ✓ tests/authored-flow.test.ts (34 tests) 743ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 753ms + ✓ tests/hosted-base-snapshot.test.ts (6 tests) 626ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 598ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 78ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 399ms + ✓ tests/authored-flow.test.ts (34 tests) 757ms + ✓ tests/hosted-extension-protocol.test.ts (13 tests) 645ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 750ms Test Files 8 passed (8) - Tests 165 passed (165) - Start at 09:08:10 - Duration 1.95s (transform 1.05s, setup 0ms, collect 5.73s, tests 2.86s, environment 1ms, prepare 483ms) + Tests 168 passed (168) + Start at 09:28:13 + Duration 1.92s (transform 1.12s, setup 0ms, collect 5.60s, tests 3.27s, environment 1ms, prepare 463ms) $ mise exec node@22 -- node --version v22.23.2 diff --git a/packages/sdk/src/canonical.ts b/packages/sdk/src/canonical.ts index e78e70e89..68ecbdc7b 100644 --- a/packages/sdk/src/canonical.ts +++ b/packages/sdk/src/canonical.ts @@ -10,6 +10,10 @@ import { createHash } from 'node:crypto'; import { snapshotJsonValue, type JsonValue } from './json-value.js'; +const ARRAY_IS_ARRAY = Array.isArray; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_KEYS = Object.keys; + /** * Serialize a value as canonical JSON: object keys sorted recursively, * arrays in order, no whitespace. Numbers are kept as-is (tokens are @@ -42,16 +46,16 @@ export function specHash(spec: unknown): string { */ function serialize(value: JsonValue): string { if (value === null || typeof value !== 'object') { - return JSON.stringify(value); + return JSON_STRINGIFY(value); } - if (Array.isArray(value)) { + if (ARRAY_IS_ARRAY(value)) { return '[' + value.map(serialize).join(',') + ']'; } const parts: string[] = []; - for (const key of Object.keys(value).sort()) { + for (const key of OBJECT_KEYS(value).sort()) { const child = value[key]; if (child === undefined) continue; - parts.push(JSON.stringify(key) + ':' + serialize(child)); + parts.push(JSON_STRINGIFY(key) + ':' + serialize(child)); } return '{' + parts.join(',') + '}'; } diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 6c3101ccc..96722e23c 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -3,11 +3,13 @@ import type { FlowExtensionManifest } from './flow-extension-manifest.js'; import { PluginError } from './plugin-manifest.js'; import { satisfiesRange } from './semver-range.js'; +const JSON_PARSE = JSON.parse; + export interface RuntimeVersions { readonly sdk: string; readonly surface: string } /** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */ export function runtimeVersions(): RuntimeVersions { - const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record }; + const pkg = JSON_PARSE(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record }; return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! }; } diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 0969fa4d2..c0f8471e3 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -9,6 +9,8 @@ import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock import { PluginError } from './plugin-manifest.js'; import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; +const JSON_PARSE = JSON.parse; + /** * Compose schema-2 flow extensions onto a base authored flow. * @@ -215,7 +217,7 @@ async function loadOne( if (manifestBytes === undefined) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json is missing.`); if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`); let input: unknown; - try { input = JSON.parse(manifestBytes.toString('utf8')); } + try { input = JSON_PARSE(manifestBytes.toString('utf8')); } catch { throw new PluginError('plugin_manifest_invalid', `${ref}: flows-plugin.json is not valid JSON.`); } const manifest = validateFlowExtensionManifest(input); if (manifest.name !== lock.name || manifest.version !== lock.version) throw new PluginError('plugin_source_drift', `${ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${lock.name}@${lock.version}.`); diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index aa48f9493..4b4ccac71 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -34,6 +34,12 @@ export type { } from './hosted-extension-runtime.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; +const ARRAY_IS_ARRAY = Array.isArray; +const JSON_PARSE = JSON.parse; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = RegExp.prototype.test; const BABYSITTER_REF = 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter'; const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; @@ -200,7 +206,7 @@ export async function selectHostedExtensionForRuntime( } async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { - if (!/^[a-f0-9]{64}$/.test(artifact.digest) || !/^[a-f0-9]{64}$/.test(artifact.manifestSha256)) { + if (!matches(/^[a-f0-9]{64}$/, artifact.digest) || !matches(/^[a-f0-9]{64}$/, artifact.manifestSha256)) { throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extension digests are malformed.`); } const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); @@ -212,7 +218,7 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise; - const keys = Object.keys(receipt).sort(); + const receipt = snapshot as Record; + const keys = OBJECT_KEYS(receipt).sort(); if (keys.length !== 2 || keys[0] !== 'receiptId' || keys[1] !== 'status' - || typeof receipt.receiptId !== 'string' || !RECEIPT_ID.test(receipt.receiptId) + || typeof receipt.receiptId !== 'string' || !matches(RECEIPT_ID, receipt.receiptId) || (receipt.status !== 'queued' && receipt.status !== 'duplicate')) { throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); } return Object.freeze({ receiptId: receipt.receiptId, status: receipt.status }); } +function matches(pattern: RegExp, value: string): boolean { + return REGEXP_TEST.call(pattern, value); +} + function record(value: unknown, what: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if (typeof value !== 'object' || value === null || ARRAY_IS_ARRAY(value)) { throw new PluginError('plugin_event_unroutable', `${what} must be an object.`); } return value as Record; @@ -353,7 +367,7 @@ function record(value: unknown, what: string): Record { function exactRecord(value: unknown, what: string, keys: readonly string[]): Record { const object = record(value, what); - const actual = Object.keys(object).sort(); + const actual = OBJECT_KEYS(object).sort(); const expected = [...keys].sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { throw new PluginError('plugin_event_unroutable', `${what} must contain exactly ${keys.join(', ')}.`); @@ -368,8 +382,8 @@ function optionalRecord( required: readonly string[], ): Record { const object = record(value, what); - const extra = Object.keys(object).filter(key => !allowed.includes(key)); - const missing = required.filter(key => !Object.hasOwn(object, key)); + const extra = OBJECT_KEYS(object).filter(key => !allowed.includes(key)); + const missing = required.filter(key => !OBJECT_HAS_OWN(object, key)); if (extra.length > 0 || missing.length > 0) { throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); } diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 954423b0e..4e9167945 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -1,10 +1,14 @@ import type { ChildProcess } from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; +import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const MAX_FRAME_BYTES = 256 * 1024; const MAX_STDERR_BYTES = 16 * 1024; +const JSON_PARSE = JSON.parse; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_KEYS = Object.keys; export interface HostedExtensionProtocolResult { readonly completionReason: 'success'; @@ -13,11 +17,16 @@ export interface HostedExtensionProtocolResult { /** A bounded JSON copy, stripped of prototypes and behavior. */ export function boundedJsonSnapshot(value: unknown, what: string): unknown { - const encoded = JSON.stringify(value); - if (encoded === undefined || Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { + let snapshot: ReturnType; + try { snapshot = snapshotJsonValue(value, what); } + catch { throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); } - return JSON.parse(encoded) as unknown; + const encoded = JSON_STRINGIFY(snapshot); + if (Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { + throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); + } + return snapshot; } /** @@ -90,7 +99,7 @@ export async function exchangeHostedExtension( const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); let message: Record; try { - const parsed = JSON.parse(line) as unknown; + const parsed = JSON_PARSE(line) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return refuse('Hosted extension emitted a non-object protocol frame.'); } @@ -113,11 +122,11 @@ export async function exchangeHostedExtension( const snapshot = boundedJsonSnapshot(value, 'hosted capability result'); capabilityState = 'completed'; if (deferredProtocolError !== undefined) return finish(deferredProtocolError); - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); + stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); } catch (error) { capabilityError = failure(error); capabilityState = 'failed'; - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); finish(capabilityError); } }, @@ -125,7 +134,7 @@ export async function exchangeHostedExtension( if (settled) return; capabilityError = failure(error); capabilityState = 'failed'; - stdin.write(`${JSON.stringify({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); finish(capabilityError); }, ); @@ -159,12 +168,12 @@ export async function exchangeHostedExtension( + (stderrText === '' ? '' : `: ${stderrText}`), ); }); - stdin.write(`${JSON.stringify(request)}\n`); + stdin.write(`${JSON_STRINGIFY(request)}\n`); }); } function hasExactKeys(value: Record, keys: readonly string[]): boolean { - const actual = Object.keys(value).sort(); + const actual = OBJECT_KEYS(value).sort(); const expected = [...keys].sort(); return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 154963e0f..56c7c1692 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -15,6 +15,8 @@ import { import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; +const JSON_PARSE = JSON.parse; +const JSON_STRINGIFY = JSON.stringify; const DEFAULT_TIMEOUT_MS = 10_000; // These hard limits are inherited across prlimit -> bubblewrap -> Node and its // descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 @@ -134,7 +136,7 @@ function resolveSurfaceRoot(expectedVersion: string, override?: string): string const packageJson = join(directory, 'package.json'); if (existsSync(packageJson)) { try { - const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; + const manifest = JSON_PARSE(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { return realpathSync(directory); } @@ -150,7 +152,7 @@ function checkedSurfaceRoot(root: string, expectedVersion: string): string { try { real = realpathSync(root); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } try { - const manifest = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8')) as { + const manifest = JSON_PARSE(readFileSync(join(real, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown; }; if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) return real; @@ -173,7 +175,7 @@ async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promi return { file, bytes, expected }; }); await Promise.all([ - writeFile(join(directory, 'package.json'), JSON.stringify({ + writeFile(join(directory, 'package.json'), JSON_STRINGIFY({ name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, }), { mode: 0o400, flag: 'wx' }), writeFile(join(directory, 'index.js'), diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index ec49f8c09..f90190489 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -1,5 +1,15 @@ import { isProxy } from 'node:util/types'; +const ARRAY_IS_ARRAY = Array.isArray; +const JSON_STRINGIFY = JSON.stringify; +const NUMBER_IS_FINITE = Number.isFinite; +const NUMBER_IS_INTEGER = Number.isInteger; +const OBJECT_CREATE = Object.create; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; +const REFLECT_OWN_KEYS = Reflect.ownKeys; + export type JsonValue = | null | boolean @@ -16,7 +26,7 @@ export function snapshotJsonValue(value: unknown, at: string): JsonValue { function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonValue { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; if (typeof value === 'number') { - if (Number.isFinite(value)) return value; + if (NUMBER_IS_FINITE(value)) return value; throw nonJson(at, 'numbers must be finite'); } if (typeof value !== 'object') { @@ -29,7 +39,7 @@ function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonV if (ancestors.has(value)) throw nonJson(at, 'cycles are not allowed'); ancestors.add(value); try { - return Array.isArray(value) + return ARRAY_IS_ARRAY(value) ? snapshotArray(value, at, ancestors) : snapshotObject(value, at, ancestors); } finally { @@ -42,7 +52,7 @@ function snapshotArray( at: string, ancestors: WeakSet, ): JsonValue[] { - const keys = Reflect.ownKeys(value); + const keys = REFLECT_OWN_KEYS(value); for (const key of keys) { if (key === 'length') continue; if (typeof key !== 'string' || !isArrayIndex(key, value.length)) { @@ -51,11 +61,11 @@ function snapshotArray( } const out: JsonValue[] = []; for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, String(index)); if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); out.push(snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors)); } - return Object.freeze(out) as unknown as JsonValue[]; + return OBJECT_FREEZE(out) as unknown as JsonValue[]; } function snapshotObject( @@ -63,15 +73,15 @@ function snapshotObject( at: string, ancestors: WeakSet, ): { [key: string]: JsonValue } { - const prototype = Object.getPrototypeOf(value); + const prototype = OBJECT_GET_PROTOTYPE_OF(value); if (prototype !== Object.prototype && prototype !== null) { throw nonJson(at, 'only plain objects are allowed'); } - const out = Object.create(null) as { [key: string]: JsonValue }; - for (const key of Reflect.ownKeys(value)) { + const out = OBJECT_CREATE(null) as { [key: string]: JsonValue }; + for (const key of REFLECT_OWN_KEYS(value)) { if (typeof key !== 'string') throw nonJson(at, 'symbol keys are not allowed'); const childAt = propertyPath(at, key); - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, key); if (descriptor === undefined) throw nonJson(childAt, 'missing property descriptor'); const child = descriptorValue(descriptor, childAt); // JSON.stringify and the pre-existing compiler omit undefined object @@ -79,7 +89,7 @@ function snapshotObject( if (child === undefined) continue; out[key] = snapshot(child, childAt, ancestors); } - return Object.freeze(out); + return OBJECT_FREEZE(out); } function snapshotDescriptor( @@ -98,11 +108,11 @@ function descriptorValue(descriptor: PropertyDescriptor, at: string): unknown { function isArrayIndex(key: string, length: number): boolean { const index = Number(key); - return Number.isInteger(index) && index >= 0 && index < length && String(index) === key; + return NUMBER_IS_INTEGER(index) && index >= 0 && index < length && String(index) === key; } function propertyPath(at: string, key: string): string { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${at}.${key}` : `${at}[${JSON.stringify(key)}]`; + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${at}.${key}` : `${at}[${JSON_STRINGIFY(key)}]`; } function nonJson(at: string, detail: string): Error { diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 997fbe6c8..a86d59276 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -17,6 +17,7 @@ import { PluginError } from './plugin-manifest.js'; export const PLUGIN_STORE = '.flows/plugins'; const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; +const JSON_PARSE = JSON.parse; const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); @@ -161,7 +162,7 @@ async function readVerifiedStoredPluginFiles( const raw = manifest.toString('utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); let entries: { path: string; sha256: string; bytes: number }[]; - try { entries = JSON.parse(raw); if (!Array.isArray(entries)) throw new Error(); } + try { entries = JSON_PARSE(raw); if (!Array.isArray(entries)) throw new Error(); } catch { return drift('manifest.json is not a manifest'); } if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`); const paths = new Set(); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 9680c1cc7..1d7d26959 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -79,6 +79,26 @@ function hostileImport(frames: readonly unknown[]): string { `; } +function normalImport(): string { + return ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { + owner: input.pullRequest.owner, + repository: input.pullRequest.repo, + number: input.pullRequest.number, + }, + } }); + f.done('success'); + }); + `; +} + const dispatch = () => hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }); @@ -91,6 +111,67 @@ async function waitForInvocation(invoked: Promise): Promise { } describe('hosted extension hostile protocol', () => { + it.each(['inherited toJSON', 'stateful getter'] as const)( + 'refuses a delivery descriptor with %s before the adapter', async attack => { + const input = descriptor(); + let hostile: unknown = input; + if (attack === 'inherited toJSON') { + hostile = Object.assign(Object.create({ + toJSON: () => ({ ...input, pullRequest: { owner: 'attacker', repo: 'other', number: 999 } }), + }), input); + } else { + Object.defineProperty(input.pullRequest, 'owner', { + enumerable: true, + get: () => 'AgentWorkforce', + }); + } + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: hostile, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }, + ); + + it.runIf(process.platform === 'linux')( + 'uses captured JSON intrinsics for the complete parent boundary', async () => { + const installed = await artifact(normalImport()); + const validated = validateFlowExtensionManifest(manifest()); + const trustedDispatch = dispatch(); + const stringify = Object.getOwnPropertyDescriptor(JSON, 'stringify')!; + const parse = Object.getOwnPropertyDescriptor(JSON, 'parse')!; + const calls: unknown[] = []; + let result: Awaited>; + try { + Object.defineProperty(JSON, 'stringify', { ...stringify, value: () => '{"forged":true}' }); + Object.defineProperty(JSON, 'parse', { ...parse, value: () => ({ forged: true }) }); + result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validated, + dispatch: trustedDispatch, + input: descriptor(), + babysitterTurn: { queue: async request => { + calls.push(request); + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + }); + } finally { + Object.defineProperty(JSON, 'stringify', stringify); + Object.defineProperty(JSON, 'parse', parse); + } + expect(result!).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }, + ); + it('rejects completion while an exact direct call is pending', async () => { let settle!: (value: unknown) => void; const adapter = new Promise(resolve => { settle = resolve; }); From b975710d75b8b01f25bf6505dced9424afbc0bc8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 09:47:21 -0700 Subject: [PATCH 20/80] fix(sdk): bound hostile delivery traversal Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- evidence/babysitter-gate8-isolation/README.md | 6 +- .../verification.txt | 20 ++-- packages/sdk/src/canonical.ts | 14 ++- .../sdk/src/hosted-extension-isolation.ts | 82 ++++++++++---- packages/sdk/src/hosted-extension-protocol.ts | 16 ++- packages/sdk/src/json-value.ts | 104 +++++++++++++++--- .../tests/hosted-extension-protocol.test.ts | 39 +++++++ 7 files changed, 223 insertions(+), 58 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index e7d6876d8..b2c6e202d 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -27,8 +27,10 @@ The enforced boundary is: - clone the delivery descriptor into behavior-free, frozen JSON with captured parent intrinsics before validation or serialization, rejecting proxies, accessors, inherited `toJSON`, symbol keys, cycles, holes, and extra fields; - then validate that snapshot against the symbol-branded verified dispatch - before import; + enforce depth, node-count, and encoded-byte limits incrementally while + traversing (including repeatedly shared subtrees), and validate exact keys + without ambient array methods; then validate that snapshot against the + symbol-branded verified dispatch before import; - import and execute the matching handler only inside a bubblewrap namespace plus Node's permission model, with no network, writable filesystem, inherited environment, child process, workspace mount, MCP, helpers, harnesses, or base diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index fc8cc21e1..8e09814b6 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -21,19 +21,19 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms - ✓ tests/hosted-base-snapshot.test.ts (6 tests) 626ms - ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 598ms + ✓ tests/hosted-base-snapshot.test.ts (6 tests) 362ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 343ms ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms - ✓ tests/preflight.test.ts (59 tests) 78ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 399ms - ✓ tests/authored-flow.test.ts (34 tests) 757ms - ✓ tests/hosted-extension-protocol.test.ts (13 tests) 645ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 750ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 396ms + ✓ tests/authored-flow.test.ts (34 tests) 756ms + ✓ tests/hosted-extension-protocol.test.ts (15 tests) 655ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 725ms Test Files 8 passed (8) - Tests 168 passed (168) - Start at 09:28:13 - Duration 1.92s (transform 1.12s, setup 0ms, collect 5.60s, tests 3.27s, environment 1ms, prepare 463ms) + Tests 170 passed (170) + Start at 09:46:29 + Duration 1.96s (transform 1.17s, setup 0ms, collect 6.04s, tests 3.01s, environment 1ms, prepare 523ms) $ mise exec node@22 -- node --version v22.23.2 diff --git a/packages/sdk/src/canonical.ts b/packages/sdk/src/canonical.ts index 68ecbdc7b..4b13f2ff0 100644 --- a/packages/sdk/src/canonical.ts +++ b/packages/sdk/src/canonical.ts @@ -11,6 +11,10 @@ import { createHash } from 'node:crypto'; import { snapshotJsonValue, type JsonValue } from './json-value.js'; const ARRAY_IS_ARRAY = Array.isArray; +const ARRAY_JOIN = Array.prototype.join; +const ARRAY_MAP = Array.prototype.map; +const ARRAY_PUSH = Array.prototype.push; +const ARRAY_SORT = Array.prototype.sort; const JSON_STRINGIFY = JSON.stringify; const OBJECT_KEYS = Object.keys; @@ -49,13 +53,15 @@ function serialize(value: JsonValue): string { return JSON_STRINGIFY(value); } if (ARRAY_IS_ARRAY(value)) { - return '[' + value.map(serialize).join(',') + ']'; + return '[' + ARRAY_JOIN.call(ARRAY_MAP.call(value, serialize), ',') + ']'; } const parts: string[] = []; - for (const key of OBJECT_KEYS(value).sort()) { + const keys = OBJECT_KEYS(value); + ARRAY_SORT.call(keys); + for (const key of keys) { const child = value[key]; if (child === undefined) continue; - parts.push(JSON_STRINGIFY(key) + ':' + serialize(child)); + ARRAY_PUSH.call(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); } - return '{' + parts.join(',') + '}'; + return '{' + ARRAY_JOIN.call(parts, ',') + '}'; } diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 4b4ccac71..c0d4dd8ef 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -38,8 +38,10 @@ const ARRAY_IS_ARRAY = Array.isArray; const JSON_PARSE = JSON.parse; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_FREEZE = Object.freeze; const OBJECT_KEYS = Object.keys; const REGEXP_TEST = RegExp.prototype.test; +const STRING_STARTS_WITH = String.prototype.startsWith; const BABYSITTER_REF = 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter'; const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; @@ -188,12 +190,12 @@ export async function selectHostedExtensionForRuntime( const manifest = await verifiedManifest(artifact); assertCompatible(manifest, versions); assertBaseCompatible(manifest, base); - if (hostedManifestRoutes(manifest, identity)) matches.push({ artifact, manifest }); + if (hostedManifestRoutes(manifest, identity)) matches[matches.length] = { artifact, manifest }; } if (matches.length > 1) { throw new PluginError( 'plugin_event_ambiguous', - `Hosted event matches multiple extension manifests (${matches.map(match => match.manifest.name).join(', ')}).`, + `Hosted event matches multiple extension manifests (${manifestNames(matches)}).`, ); } const selected = matches[0]; @@ -210,7 +212,10 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise file.path === 'flows-plugin.json')?.data; + let bytes: Buffer | undefined; + for (let index = 0; index < stored.length; index += 1) { + if (stored[index]!.path === 'flows-plugin.json') bytes = stored[index]!.data; + } if (bytes === undefined) { throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json is missing.`); } @@ -239,8 +244,11 @@ async function assertPinnedBabysitter(artifact: HostedExtensionArtifact): Promis ); } const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); - if (stored.some(file => file.path === 'node_modules' || file.path.startsWith('node_modules/'))) { - throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); + for (let index = 0; index < stored.length; index += 1) { + const path = stored[index]!.path; + if (path === 'node_modules' || STRING_STARTS_WITH.call(path, 'node_modules/')) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); + } } } @@ -286,10 +294,16 @@ export function hostedManifestRoutes( manifest: Pick, identity: { readonly provider: string; readonly event: string; readonly action?: string }, ): boolean { - return manifest.triggers.some(trigger => trigger.provider === identity.provider - && trigger.event === identity.event - && (trigger.actions.length === 0 - || (identity.action !== undefined && trigger.actions.includes(identity.action)))); + for (let triggerIndex = 0; triggerIndex < manifest.triggers.length; triggerIndex += 1) { + const trigger = manifest.triggers[triggerIndex]!; + if (trigger.provider !== identity.provider || trigger.event !== identity.event) continue; + if (trigger.actions.length === 0) return true; + if (identity.action === undefined) continue; + for (let actionIndex = 0; actionIndex < trigger.actions.length; actionIndex += 1) { + if (trigger.actions[actionIndex] === identity.action) return true; + } + } + return false; } function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { @@ -345,13 +359,12 @@ function babysitterReceipt(value: unknown): unknown { throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); } const receipt = snapshot as Record; - const keys = OBJECT_KEYS(receipt).sort(); - if (keys.length !== 2 || keys[0] !== 'receiptId' || keys[1] !== 'status' + if (!hasExactKeys(receipt, ['receiptId', 'status']) || typeof receipt.receiptId !== 'string' || !matches(RECEIPT_ID, receipt.receiptId) || (receipt.status !== 'queued' && receipt.status !== 'duplicate')) { throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); } - return Object.freeze({ receiptId: receipt.receiptId, status: receipt.status }); + return OBJECT_FREEZE({ receiptId: receipt.receiptId, status: receipt.status }); } function matches(pattern: RegExp, value: string): boolean { @@ -367,10 +380,8 @@ function record(value: unknown, what: string): Record { function exactRecord(value: unknown, what: string, keys: readonly string[]): Record { const object = record(value, what); - const actual = OBJECT_KEYS(object).sort(); - const expected = [...keys].sort(); - if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { - throw new PluginError('plugin_event_unroutable', `${what} must contain exactly ${keys.join(', ')}.`); + if (!hasExactKeys(object, keys)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); } return object; } @@ -382,14 +393,45 @@ function optionalRecord( required: readonly string[], ): Record { const object = record(value, what); - const extra = OBJECT_KEYS(object).filter(key => !allowed.includes(key)); - const missing = required.filter(key => !OBJECT_HAS_OWN(object, key)); - if (extra.length > 0 || missing.length > 0) { - throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + const actual = OBJECT_KEYS(object); + for (let index = 0; index < actual.length; index += 1) { + if (!contains(allowed, actual[index]!)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } + } + for (let index = 0; index < required.length; index += 1) { + if (!OBJECT_HAS_OWN(object, required[index]!)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } } return object; } +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + if (OBJECT_KEYS(value).length !== keys.length) return false; + for (let index = 0; index < keys.length; index += 1) { + if (!OBJECT_HAS_OWN(value, keys[index]!)) return false; + } + return true; +} + +function contains(values: readonly string[], expected: string): boolean { + for (let index = 0; index < values.length; index += 1) { + if (values[index] === expected) return true; + } + return false; +} + +function manifestNames( + matches: readonly { readonly manifest: FlowExtensionManifest }[], +): string { + let value = ''; + for (let index = 0; index < matches.length; index += 1) { + value += `${index === 0 ? '' : ', '}${matches[index]!.manifest.name}`; + } + return value; +} + function sha256(value: Uint8Array | string): string { return createHash('sha256').update(value).digest('hex'); } diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 4e9167945..b64de06f6 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -8,7 +8,13 @@ const MAX_FRAME_BYTES = 256 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; +const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; +const SNAPSHOT_LIMITS = Object.freeze({ + maxDepth: 64, + maxNodes: 262_144, + maxBytes: MAX_FRAME_BYTES, +}); export interface HostedExtensionProtocolResult { readonly completionReason: 'success'; @@ -18,7 +24,7 @@ export interface HostedExtensionProtocolResult { /** A bounded JSON copy, stripped of prototypes and behavior. */ export function boundedJsonSnapshot(value: unknown, what: string): unknown { let snapshot: ReturnType; - try { snapshot = snapshotJsonValue(value, what); } + try { snapshot = snapshotJsonValue(value, what, SNAPSHOT_LIMITS); } catch { throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); } @@ -173,7 +179,9 @@ export async function exchangeHostedExtension( } function hasExactKeys(value: Record, keys: readonly string[]): boolean { - const actual = OBJECT_KEYS(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); + if (OBJECT_KEYS(value).length !== keys.length) return false; + for (let index = 0; index < keys.length; index += 1) { + if (!OBJECT_HAS_OWN(value, keys[index]!)) return false; + } + return true; } diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index f90190489..7b2d5537c 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -1,14 +1,23 @@ import { isProxy } from 'node:util/types'; const ARRAY_IS_ARRAY = Array.isArray; +const ARRAY_PUSH = Array.prototype.push; +const BUFFER_BYTE_LENGTH = Buffer.byteLength; const JSON_STRINGIFY = JSON.stringify; +const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; const NUMBER_IS_INTEGER = Number.isInteger; const OBJECT_CREATE = Object.create; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; +const OBJECT_PROTOTYPE = Object.prototype; const REFLECT_OWN_KEYS = Reflect.ownKeys; +const REGEXP_TEST = RegExp.prototype.test; +const STRING = String; +const WEAK_SET_ADD = WeakSet.prototype.add; +const WEAK_SET_DELETE = WeakSet.prototype.delete; +const WEAK_SET_HAS = WeakSet.prototype.has; export type JsonValue = | null @@ -18,15 +27,40 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +export interface JsonSnapshotLimits { + readonly maxDepth: number; + readonly maxNodes: number; + readonly maxBytes: number; +} + +interface SnapshotBudget { + readonly limits?: JsonSnapshotLimits; + nodes: number; + bytes: number; +} + /** Copy runtime input into frozen, behavior-free JSON data. */ -export function snapshotJsonValue(value: unknown, at: string): JsonValue { - return snapshot(value, at, new WeakSet()); +export function snapshotJsonValue(value: unknown, at: string, limits?: JsonSnapshotLimits): JsonValue { + return snapshot(value, at, new WeakSet(), { limits, nodes: 0, bytes: 0 }, 0); } -function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; +function snapshot( + value: unknown, + at: string, + ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, +): JsonValue { + consumeNode(budget, at, depth); + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + consumeBytes(budget, encodedBytes(value), at); + return value; + } if (typeof value === 'number') { - if (NUMBER_IS_FINITE(value)) return value; + if (NUMBER_IS_FINITE(value)) { + consumeBytes(budget, encodedBytes(value), at); + return value; + } throw nonJson(at, 'numbers must be finite'); } if (typeof value !== 'object') { @@ -36,14 +70,14 @@ function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonV // Node and Bun expose this trap-free brand check, so reject before touching // its prototype, keys, descriptors, or identity collection. if (isProxy(value)) throw nonJson(at, 'Proxy objects are not allowed'); - if (ancestors.has(value)) throw nonJson(at, 'cycles are not allowed'); - ancestors.add(value); + if (WEAK_SET_HAS.call(ancestors, value)) throw nonJson(at, 'cycles are not allowed'); + WEAK_SET_ADD.call(ancestors, value); try { return ARRAY_IS_ARRAY(value) - ? snapshotArray(value, at, ancestors) - : snapshotObject(value, at, ancestors); + ? snapshotArray(value, at, ancestors, budget, depth) + : snapshotObject(value, at, ancestors, budget, depth); } finally { - ancestors.delete(value); + WEAK_SET_DELETE.call(ancestors, value); } } @@ -51,7 +85,10 @@ function snapshotArray( value: unknown[], at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): JsonValue[] { + consumeBytes(budget, 2, at); const keys = REFLECT_OWN_KEYS(value); for (const key of keys) { if (key === 'length') continue; @@ -61,9 +98,10 @@ function snapshotArray( } const out: JsonValue[] = []; for (let index = 0; index < value.length; index += 1) { - const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, String(index)); + if (index > 0) consumeBytes(budget, 1, at); + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, STRING(index)); if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); - out.push(snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors)); + ARRAY_PUSH.call(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); } return OBJECT_FREEZE(out) as unknown as JsonValue[]; } @@ -72,12 +110,16 @@ function snapshotObject( value: object, at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): { [key: string]: JsonValue } { const prototype = OBJECT_GET_PROTOTYPE_OF(value); - if (prototype !== Object.prototype && prototype !== null) { + if (prototype !== OBJECT_PROTOTYPE && prototype !== null) { throw nonJson(at, 'only plain objects are allowed'); } + consumeBytes(budget, 2, at); const out = OBJECT_CREATE(null) as { [key: string]: JsonValue }; + let included = 0; for (const key of REFLECT_OWN_KEYS(value)) { if (typeof key !== 'string') throw nonJson(at, 'symbol keys are not allowed'); const childAt = propertyPath(at, key); @@ -87,7 +129,10 @@ function snapshotObject( // JSON.stringify and the pre-existing compiler omit undefined object // optionals. Arrays remain strict because undefined there becomes null. if (child === undefined) continue; - out[key] = snapshot(child, childAt, ancestors); + if (included > 0) consumeBytes(budget, 1, at); + consumeBytes(budget, encodedBytes(key) + 1, childAt); + out[key] = snapshot(child, childAt, ancestors, budget, depth + 1); + included += 1; } return OBJECT_FREEZE(out); } @@ -96,8 +141,10 @@ function snapshotDescriptor( descriptor: PropertyDescriptor, at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): JsonValue { - return snapshot(descriptorValue(descriptor, at), at, ancestors); + return snapshot(descriptorValue(descriptor, at), at, ancestors, budget, depth); } function descriptorValue(descriptor: PropertyDescriptor, at: string): unknown { @@ -107,12 +154,33 @@ function descriptorValue(descriptor: PropertyDescriptor, at: string): unknown { } function isArrayIndex(key: string, length: number): boolean { - const index = Number(key); - return NUMBER_IS_INTEGER(index) && index >= 0 && index < length && String(index) === key; + const index = NUMBER(key); + return NUMBER_IS_INTEGER(index) && index >= 0 && index < length && STRING(index) === key; } function propertyPath(at: string, key: string): string { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${at}.${key}` : `${at}[${JSON_STRINGIFY(key)}]`; + return REGEXP_TEST.call(/^[A-Za-z_$][A-Za-z0-9_$]*$/, key) + ? `${at}.${key}` + : `${at}[${JSON_STRINGIFY(key)}]`; +} + +function encodedBytes(value: null | boolean | number | string): number { + return BUFFER_BYTE_LENGTH(JSON_STRINGIFY(value)); +} + +function consumeNode(budget: SnapshotBudget, at: string, depth: number): void { + budget.nodes += 1; + if (budget.limits !== undefined + && (depth > budget.limits.maxDepth || budget.nodes > budget.limits.maxNodes)) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } +} + +function consumeBytes(budget: SnapshotBudget, bytes: number, at: string): void { + budget.bytes += bytes; + if (budget.limits !== undefined && budget.bytes > budget.limits.maxBytes) { + throw nonJson(at, 'snapshot byte limit exceeded'); + } } function nonJson(at: string, detail: string): Error { diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 1d7d26959..e8982f22e 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -138,6 +138,45 @@ describe('hosted extension hostile protocol', () => { }, ); + it('bounds an exponentially shared delivery graph before cloning it', async () => { + let shared: Record = { leaf: 'x' }; + for (let depth = 0; depth < 40; depth += 1) shared = { left: shared, right: shared }; + const input = { ...descriptor(), extra: shared }; + let calls = 0; + const started = Date.now(); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(calls).toBe(0); + }); + + it('rejects extra delivery fields with Array.prototype.sort poisoned', async () => { + const installed = await artifact(normalImport()); + const validated = validateFlowExtensionManifest(manifest()); + const input = { ...descriptor(), sessionId: 'forged-session' }; + const sort = Object.getOwnPropertyDescriptor(Array.prototype, 'sort')!; + let calls = 0; + try { + Object.defineProperty(Array.prototype, 'sort', { ...sort, value: () => [] }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validated, dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + } finally { + Object.defineProperty(Array.prototype, 'sort', sort); + } + expect(calls).toBe(0); + }); + it.runIf(process.platform === 'linux')( 'uses captured JSON intrinsics for the complete parent boundary', async () => { const installed = await artifact(normalImport()); From 7f7230e12f412ebb3e31dd549388a5e0ef8fb2d1 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 10:03:07 -0700 Subject: [PATCH 21/80] fix(sdk): bound hosted declaration parsing Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- evidence/babysitter-gate8-isolation/README.md | 9 +- .../verification.txt | 23 ++-- packages/sdk/src/hosted-extension-runtime.ts | 111 +++++++++++++++++- packages/sdk/src/json-value.ts | 46 ++++++-- .../sdk/tests/hosted-base-snapshot.test.ts | 14 +++ .../tests/hosted-extension-protocol.test.ts | 16 +++ 6 files changed, 190 insertions(+), 29 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index b2c6e202d..24a88113b 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -14,9 +14,9 @@ The enforced boundary is: every dispatch rechecks the current declarations plus complete project source; base stdout, globals, process termination, relative/package imports, and an earlier module cache cannot forge the parent-pinned identity; source - directory entries are streamed beneath a shared entry bound; nonblocking - no-follow descriptors and explicitly bounded reads enforce the byte limit - before contents are buffered; + directory entries are streamed beneath a shared entry bound; `flows.json` + and `flows.lock.json` plus source files use nonblocking no-follow descriptors + and explicitly bounded reads before contents are buffered or parsed; - reverify every content-addressed artifact, lock metadata, manifest hash, base/runtime compatibility, and route uniqueness using bounded descriptor reads that enforce the fetched plugin's 500-file, 256-KB-per-file, and 2-MB @@ -28,7 +28,8 @@ The enforced boundary is: parent intrinsics before validation or serialization, rejecting proxies, accessors, inherited `toJSON`, symbol keys, cycles, holes, and extra fields; enforce depth, node-count, and encoded-byte limits incrementally while - traversing (including repeatedly shared subtrees), and validate exact keys + traversing (including repeatedly shared subtrees), count string escaping + without materializing an unbounded encoded copy, and validate exact keys without ambient array methods; then validate that snapshot against the symbol-branded verified dispatch before import; - import and execute the matching handler only inside a bubblewrap namespace diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 8e09814b6..2d9d6537a 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -7,6 +7,7 @@ bubblewrap 0.12.0 prlimit from util-linux 2.42.3 v26.8.1 +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts > @relayflows/sdk@2.0.26 typecheck @@ -20,20 +21,20 @@ $ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tes RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms - ✓ tests/hosted-base-snapshot.test.ts (6 tests) 362ms - ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 343ms + ✓ tests/plugin-store-bounds.test.ts (3 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 650ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 627ms ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms - ✓ tests/preflight.test.ts (59 tests) 100ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 396ms - ✓ tests/authored-flow.test.ts (34 tests) 756ms - ✓ tests/hosted-extension-protocol.test.ts (15 tests) 655ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 725ms + ✓ tests/preflight.test.ts (59 tests) 107ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 431ms + ✓ tests/authored-flow.test.ts (34 tests) 762ms + ✓ tests/hosted-extension-protocol.test.ts (16 tests) 627ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 811ms Test Files 8 passed (8) - Tests 170 passed (170) - Start at 09:46:29 - Duration 1.96s (transform 1.17s, setup 0ms, collect 6.04s, tests 3.01s, environment 1ms, prepare 523ms) + Tests 173 passed (173) + Start at 10:01:55 + Duration 2.08s (transform 1.41s, setup 0ms, collect 6.28s, tests 3.41s, environment 1ms, prepare 418ms) $ mise exec node@22 -- node --version v22.23.2 diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 5e16c068b..5e296da61 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; +import { constants, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'; import { readFile, realpath } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; import { createHostedBaseSnapshot, @@ -10,12 +11,23 @@ import { } from './hosted-base-snapshot.js'; import { PluginError } from './plugin-manifest.js'; import { findPluginProject } from './plugin-loader.js'; -import { reconcileDeclaredExtensions } from './plugin-lock.js'; +import { + PLUGIN_LOCK_FILE, + PLUGIN_LOCK_VERSION, + parsePluginLock, + type PluginLockEntry, +} from './plugin-lock.js'; +import { canonicalPluginRef, isGithubPluginRef, type PluginSourceRef } from './plugin-source.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; const INSTALLATION_AUTHORITY = new WeakSet(); const BASE_AUTHORITY = new WeakSet(); const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; +const MAX_DECLARATION_BYTES = 1024 * 1024; +const DECLARATION_READ_FLAGS = constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_NONBLOCK ?? 0); +const JSON_PARSE = JSON.parse; interface RuntimeGeneration { readonly origin: string; @@ -132,7 +144,7 @@ async function installationAt( installation: installation([], generation), declarations: canonicalize([]), }; const artifacts: HostedExtensionArtifact[] = []; - const declared = reconcileDeclaredExtensions(root); + const declared = hostedDeclaredExtensions(root); for (const { ref, entry } of declared) { const directory = pluginStoreDirectory(root, entry.name, entry.digest); await verifyStoredPlugin(directory, entry.digest); @@ -156,12 +168,12 @@ function declaredExtensions(origin: string): { readonly signature: string } { return { signature: root === undefined ? canonicalize([]) - : declarationSignature(reconcileDeclaredExtensions(root)), + : declarationSignature(hostedDeclaredExtensions(root)), }; } function declarationSignature( - declared: ReturnType, + declared: ReturnType, ): string { return canonicalize(declared.map(({ ref, entry }) => ({ ref, @@ -172,6 +184,95 @@ function declarationSignature( }))); } +function hostedDeclaredExtensions( + root: string, +): readonly { ref: string; entry: PluginLockEntry; source: PluginSourceRef }[] { + if (existsSync(join(root, 'flows.json.tmp')) || existsSync(join(root, `${PLUGIN_LOCK_FILE}.tmp`))) { + throw new PluginError('plugin_lock_invalid', 'Hosted extension declarations have a pending transaction.'); + } + let config: unknown; + try { config = JSON_PARSE(readBoundedDeclaration(join(root, 'flows.json')).toString('utf8')); } + catch (error) { + if (error instanceof PluginError) throw error; + throw new PluginError('plugin_manifest_invalid', 'Invalid or oversized flows.json.'); + } + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); + } + const plugins = (config as { plugins?: unknown }).plugins; + if (plugins !== undefined && !Array.isArray(plugins)) { + throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); + } + const declared: string[] = []; + if (plugins !== undefined) { + for (let index = 0; index < plugins.length; index += 1) { + const ref = plugins[index]; + if (typeof ref !== 'string') { + throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); + } + if (isGithubPluginRef(ref)) declared[declared.length] = ref; + } + } + let lock: ReturnType = Object.freeze({ + version: PLUGIN_LOCK_VERSION, + plugins: Object.freeze([]), + }); + const lockPath = join(root, PLUGIN_LOCK_FILE); + if (existsSync(lockPath)) { + let value: unknown; + try { value = JSON_PARSE(readBoundedDeclaration(lockPath).toString('utf8')); } + catch (error) { + if (error instanceof PluginError) throw error; + throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`); + } + lock = parsePluginLock(value); + } + if (declared.length !== lock.plugins.length) { + throw new PluginError('plugin_lock_invalid', 'Hosted flows.json and flows.lock.json declarations differ.'); + } + const result: Array<{ ref: string; entry: PluginLockEntry; source: PluginSourceRef }> = []; + for (let index = 0; index < lock.plugins.length; index += 1) { + const entry = lock.plugins[index]!; + const source = Object.freeze({ ...entry.source, ref: entry.source.sha }); + const ref = canonicalPluginRef(source); + if (declared[index] !== ref) { + throw new PluginError('plugin_lock_invalid', 'Hosted flows.lock.json order differs from flows.json.plugins.'); + } + result[result.length] = Object.freeze({ ref, entry, source }); + } + return Object.freeze(result); +} + +function readBoundedDeclaration(path: string): Buffer { + let descriptor: number | undefined; + try { + descriptor = openSync(path, DECLARATION_READ_FLAGS); + const before = fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.size < 0n || before.size > BigInt(MAX_DECLARATION_BYTES)) { + throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); + } + const expected = Number(before.size); + const bytes = Buffer.allocUnsafe(expected); + let offset = 0; + while (offset < expected) { + const count = readSync(descriptor, bytes, offset, expected - offset, offset); + if (count === 0) throw new Error('short read'); + offset += count; + } + if (readSync(descriptor, Buffer.allocUnsafe(1), 0, 1, expected) !== 0) throw new Error('grew'); + const after = fstatSync(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw new Error('changed'); + } + return bytes; + } catch (error) { + if (error instanceof PluginError) throw error; + throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is unreadable or changed while reading.'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + function newGeneration(origin: string): RuntimeGeneration { return { origin, diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index 7b2d5537c..dae6c4326 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -2,7 +2,6 @@ import { isProxy } from 'node:util/types'; const ARRAY_IS_ARRAY = Array.isArray; const ARRAY_PUSH = Array.prototype.push; -const BUFFER_BYTE_LENGTH = Buffer.byteLength; const JSON_STRINGIFY = JSON.stringify; const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; @@ -52,13 +51,17 @@ function snapshot( depth: number, ): JsonValue { consumeNode(budget, at, depth); - if (value === null || typeof value === 'string' || typeof value === 'boolean') { - consumeBytes(budget, encodedBytes(value), at); + if (typeof value === 'string') { + consumeStringBytes(budget, value, at); + return value; + } + if (value === null || typeof value === 'boolean') { + consumeBytes(budget, value === null ? 4 : value ? 4 : 5, at); return value; } if (typeof value === 'number') { if (NUMBER_IS_FINITE(value)) { - consumeBytes(budget, encodedBytes(value), at); + consumeBytes(budget, STRING(value).length, at); return value; } throw nonJson(at, 'numbers must be finite'); @@ -130,7 +133,8 @@ function snapshotObject( // optionals. Arrays remain strict because undefined there becomes null. if (child === undefined) continue; if (included > 0) consumeBytes(budget, 1, at); - consumeBytes(budget, encodedBytes(key) + 1, childAt); + consumeStringBytes(budget, key, childAt); + consumeBytes(budget, 1, childAt); out[key] = snapshot(child, childAt, ancestors, budget, depth + 1); included += 1; } @@ -159,15 +163,12 @@ function isArrayIndex(key: string, length: number): boolean { } function propertyPath(at: string, key: string): string { + if (key.length > 100) return `${at}[long property]`; return REGEXP_TEST.call(/^[A-Za-z_$][A-Za-z0-9_$]*$/, key) ? `${at}.${key}` : `${at}[${JSON_STRINGIFY(key)}]`; } -function encodedBytes(value: null | boolean | number | string): number { - return BUFFER_BYTE_LENGTH(JSON_STRINGIFY(value)); -} - function consumeNode(budget: SnapshotBudget, at: string, depth: number): void { budget.nodes += 1; if (budget.limits !== undefined @@ -176,6 +177,33 @@ function consumeNode(budget: SnapshotBudget, at: string, depth: number): void { } } +/** Count JSON's UTF-8 string encoding without allocating the escaped value. */ +function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): void { + consumeBytes(budget, 2, at); + if (budget.limits !== undefined && value.length > budget.limits.maxBytes - budget.bytes) { + throw nonJson(at, 'snapshot byte limit exceeded'); + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 + || code === 0x0a || code === 0x0c || code === 0x0d) { + consumeBytes(budget, 2, at); + } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) { + if (code <= 0xdbff && index + 1 < value.length) { + const low = value.charCodeAt(index + 1); + if (low >= 0xdc00 && low <= 0xdfff) { + consumeBytes(budget, 4, at); + index += 1; + continue; + } + } + consumeBytes(budget, 6, at); + } else if (code < 0x80) consumeBytes(budget, 1, at); + else if (code < 0x800) consumeBytes(budget, 2, at); + else consumeBytes(budget, 3, at); + } +} + function consumeBytes(budget: SnapshotBudget, bytes: number, at: string): void { budget.bytes += bytes; if (budget.limits !== undefined && budget.bytes > budget.limits.maxBytes) { diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 4bfc80703..78db759e9 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -8,6 +8,7 @@ import { hostedBaseSourceDigest, removeHostedBaseSnapshot, } from '../src/hosted-base-snapshot.js'; +import { loadHostedExtensionRuntime } from '../src/hosted-extension-runtime.js'; const roots: string[] = []; afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); @@ -65,6 +66,19 @@ describe('hosted base private snapshot', () => { }); }); + it.each(['flows.json', 'flows.lock.json'])( + 'refuses oversized sparse %s before parsing it', async declaration => { + const { project, flowPath } = fixture(); + const path = join(project, declaration); + if (!existsSync(path)) writeFileSync(path, ''); + truncateSync(path, 1024 * 1024 + 1); + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('bounded regular file'), + }); + }, + ); + it('bounds a file that grows after its admitted size was checked', async () => { const { project } = fixture(); const raced = join(project, 'raced.bin'); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index e8982f22e..d75644e6f 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -156,6 +156,22 @@ describe('hosted extension hostile protocol', () => { expect(calls).toBe(0); }); + it('rejects an oversized escaped string before materializing its JSON encoding', async () => { + const input = { ...descriptor(), extra: '\0'.repeat(1024 * 1024) }; + let calls = 0; + const started = Date.now(); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(calls).toBe(0); + }); + it('rejects extra delivery fields with Array.prototype.sort poisoned', async () => { const installed = await artifact(normalImport()); const validated = validateFlowExtensionManifest(manifest()); From 6a57998201772da1ff7050c097730648f7c63f28 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 10:20:28 -0700 Subject: [PATCH 22/80] fix(sdk): stream hosted JSON properties Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- docs/CLOUD.md | 7 ++-- evidence/babysitter-gate8-isolation/README.md | 12 +++--- .../verification.txt | 33 +++++++++++++++ packages/sdk/src/json-value.ts | 40 ++++++++++++++----- .../tests/hosted-extension-protocol.test.ts | 24 +++++++++++ 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/docs/CLOUD.md b/docs/CLOUD.md index a58ed2466..8b96554af 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -396,9 +396,10 @@ the base and complete installation together with does not import extension JavaScript), then pass its two opaque, same-generation results to `runHostedCapabilityExtension`. Every dispatch rechecks the current declarations and complete project source against that -generation. The base and ordinary relative imports execute from a unique -private snapshot before the runner verifies -the complete lock-backed set, actual base compatibility, route uniqueness, and +generation. The hosted loader never executes tenant base code to establish +authority: it hashes a private snapshot and accepts only the exact reviewed, +host-pinned Software Factory source and identity. The runner then verifies the +complete lock-backed set, actual base compatibility, route uniqueness, and the exact reviewed Babysitter ref/digest/manifest before the artifact is imported inside a bubblewrap mount/PID/network/user namespace with an empty credential environment and a context exposing only diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md index 24a88113b..d0e6b777d 100644 --- a/evidence/babysitter-gate8-isolation/README.md +++ b/evidence/babysitter-gate8-isolation/README.md @@ -26,11 +26,13 @@ The enforced boundary is: that snapshot so later store replacement cannot change executed code; - clone the delivery descriptor into behavior-free, frozen JSON with captured parent intrinsics before validation or serialization, rejecting proxies, - accessors, inherited `toJSON`, symbol keys, cycles, holes, and extra fields; - enforce depth, node-count, and encoded-byte limits incrementally while - traversing (including repeatedly shared subtrees), count string escaping - without materializing an unbounded encoded copy, and validate exact keys - without ambient array methods; then validate that snapshot against the + accessors, inherited `toJSON`, cycles, holes, and extra JSON-visible fields + while stripping symbol and non-enumerable metadata; enforce depth, + property/node-count, and encoded-byte limits incrementally while traversing + (including repeatedly shared subtrees), count string escaping without + materializing an unbounded encoded copy, and enumerate JSON-visible keys + without first allocating an attacker-sized key array or using ambient array + methods; then validate that snapshot against the symbol-branded verified dispatch before import; - import and execute the matching handler only inside a bubblewrap namespace plus Node's permission model, with no network, writable filesystem, inherited diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 2d9d6537a..7ef95bcd8 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -127,3 +127,36 @@ e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff dist/provider- 8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b dist/schedule.js 4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c dist/triggers.js e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b dist/triggers/github.js + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1020ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 456ms + ✓ tests/authored-flow.test.ts (34 tests) 699ms + ✓ tests/hosted-extension-protocol.test.ts (19 tests) 720ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 809ms + + Test Files 8 passed (8) + Tests 176 passed (176) diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index dae6c4326..2a3033dfd 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -10,8 +10,8 @@ const OBJECT_CREATE = Object.create; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; +const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_PROTOTYPE = Object.prototype; -const REFLECT_OWN_KEYS = Reflect.ownKeys; const REGEXP_TEST = RegExp.prototype.test; const STRING = String; const WEAK_SET_ADD = WeakSet.prototype.add; @@ -35,12 +35,13 @@ export interface JsonSnapshotLimits { interface SnapshotBudget { readonly limits?: JsonSnapshotLimits; nodes: number; + properties: number; bytes: number; } /** Copy runtime input into frozen, behavior-free JSON data. */ export function snapshotJsonValue(value: unknown, at: string, limits?: JsonSnapshotLimits): JsonValue { - return snapshot(value, at, new WeakSet(), { limits, nodes: 0, bytes: 0 }, 0); + return snapshot(value, at, new WeakSet(), { limits, nodes: 0, properties: 0, bytes: 0 }, 0); } function snapshot( @@ -92,10 +93,18 @@ function snapshotArray( depth: number, ): JsonValue[] { consumeBytes(budget, 2, at); - const keys = REFLECT_OWN_KEYS(value); - for (const key of keys) { - if (key === 'length') continue; - if (typeof key !== 'string' || !isArrayIndex(key, value.length)) { + if (budget.limits !== undefined && value.length > budget.limits.maxNodes - budget.nodes) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } + // `Reflect.ownKeys` allocates the complete key list before a caller can + // enforce cardinality. Enumerate JSON-visible keys one at a time instead. + // Symbols and non-enumerable properties are deliberately ignored, matching + // JSON.stringify; the copied value has neither, so they cannot affect later + // validation or serialization. + for (const key in value) { + consumeProperty(budget, at); + if (!OBJECT_HAS_OWN(value, key)) continue; + if (!isArrayIndex(key, value.length)) { throw nonJson(at, 'arrays may contain only indexed data'); } } @@ -123,8 +132,13 @@ function snapshotObject( consumeBytes(budget, 2, at); const out = OBJECT_CREATE(null) as { [key: string]: JsonValue }; let included = 0; - for (const key of REFLECT_OWN_KEYS(value)) { - if (typeof key !== 'string') throw nonJson(at, 'symbol keys are not allowed'); + // Avoid materializing an attacker-sized key array in the unsandboxed + // parent. This visits only JSON-visible own string properties; symbols and + // non-enumerable properties are omitted exactly as JSON.stringify omits + // them, and cannot influence the null-prototype copy. + for (const key in value) { + consumeProperty(budget, at); + if (!OBJECT_HAS_OWN(value, key)) continue; const childAt = propertyPath(at, key); const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, key); if (descriptor === undefined) throw nonJson(childAt, 'missing property descriptor'); @@ -177,6 +191,14 @@ function consumeNode(budget: SnapshotBudget, at: string, depth: number): void { } } +function consumeProperty(budget: SnapshotBudget, at: string): void { + if (budget.limits === undefined) return; + budget.properties += 1; + if (budget.properties > budget.limits.maxNodes) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } +} + /** Count JSON's UTF-8 string encoding without allocating the escaped value. */ function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): void { consumeBytes(budget, 2, at); @@ -189,7 +211,7 @@ function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): || code === 0x0a || code === 0x0c || code === 0x0d) { consumeBytes(budget, 2, at); } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) { - if (code <= 0xdbff && index + 1 < value.length) { + if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { const low = value.charCodeAt(index + 1); if (low >= 0xdc00 && low <= 0xdfff) { consumeBytes(budget, 4, at); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index d75644e6f..17dfdb54a 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -8,6 +8,7 @@ import { runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; +import { snapshotJsonValue } from '../src/json-value.js'; import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; import { materializePlugin } from '../src/plugin-store.js'; @@ -111,6 +112,29 @@ async function waitForInvocation(invoked: Promise): Promise { } describe('hosted extension hostile protocol', () => { + it('counts a control followed by a low surrogate as two JSON escapes', () => { + expect(() => snapshotJsonValue('\0\udc00', 'delivery', { + maxDepth: 4, maxNodes: 4, maxBytes: 13, + })).toThrow(/snapshot byte limit exceeded/); + }); + + it('caps object cardinality without materializing the complete key list', () => { + const hostile = Object.create(null) as Record; + for (let index = 0; index < 10_000; index += 1) hostile[`ignored-${index}`] = undefined; + const started = Date.now(); + expect(() => snapshotJsonValue(hostile, 'delivery', { + maxDepth: 4, maxNodes: 32, maxBytes: 1024, + })).toThrow(/snapshot depth or node limit exceeded/); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('copies only JSON-visible metadata into the behavior-free snapshot', () => { + const value = { visible: 'yes' } as Record; + Object.defineProperty(value, 'hidden', { value: 'no', enumerable: false }); + value[Symbol('hidden')] = 'no'; + expect(snapshotJsonValue(value, 'delivery')).toEqual({ visible: 'yes' }); + }); + it.each(['inherited toJSON', 'stateful getter'] as const)( 'refuses a delivery descriptor with %s before the adapter', async attack => { const input = descriptor(); From 6928730565d190f909e28de7e013ff28328800ee Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 10:30:41 -0700 Subject: [PATCH 23/80] fix(sdk): reject hostile array prototypes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 18 +++++++++--------- packages/sdk/src/json-value.ts | 5 +++++ .../tests/hosted-extension-protocol.test.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 7ef95bcd8..dfd541871 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -149,14 +149,14 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms - ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms - ✓ tests/preflight.test.ts (59 tests) 102ms - ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1020ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 456ms - ✓ tests/authored-flow.test.ts (34 tests) 699ms - ✓ tests/hosted-extension-protocol.test.ts (19 tests) 720ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 809ms + ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 110ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 962ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 388ms + ✓ tests/authored-flow.test.ts (34 tests) 766ms + ✓ tests/hosted-extension-protocol.test.ts (20 tests) 677ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 763ms Test Files 8 passed (8) - Tests 176 passed (176) + Tests 177 passed (177) diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index 2a3033dfd..1a6c147e1 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -2,6 +2,7 @@ import { isProxy } from 'node:util/types'; const ARRAY_IS_ARRAY = Array.isArray; const ARRAY_PUSH = Array.prototype.push; +const ARRAY_PROTOTYPE = Array.prototype; const JSON_STRINGIFY = JSON.stringify; const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; @@ -92,6 +93,10 @@ function snapshotArray( budget: SnapshotBudget, depth: number, ): JsonValue[] { + if (OBJECT_GET_PROTOTYPE_OF(value) !== ARRAY_PROTOTYPE + || OBJECT_GET_PROTOTYPE_OF(ARRAY_PROTOTYPE) !== OBJECT_PROTOTYPE) { + throw nonJson(at, 'only arrays with the intrinsic prototype are allowed'); + } consumeBytes(budget, 2, at); if (budget.limits !== undefined && value.length > budget.limits.maxNodes - budget.nodes) { throw nonJson(at, 'snapshot depth or node limit exceeded'); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 17dfdb54a..04c432d0f 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -135,6 +135,18 @@ describe('hosted extension hostile protocol', () => { expect(snapshotJsonValue(value, 'delivery')).toEqual({ visible: 'yes' }); }); + it('rejects a Proxy in an array prototype chain without executing its traps', () => { + let traps = 0; + const prototype = new Proxy(Array.prototype, { + ownKeys: () => { traps += 1; return []; }, + getOwnPropertyDescriptor: () => { traps += 1; return undefined; }, + }); + const value: unknown[] = []; + Object.setPrototypeOf(value, prototype); + expect(() => snapshotJsonValue(value, 'delivery')).toThrow(/intrinsic prototype/); + expect(traps).toBe(0); + }); + it.each(['inherited toJSON', 'stateful getter'] as const)( 'refuses a delivery descriptor with %s before the adapter', async attack => { const input = descriptor(); From 080b87563d3d5e288bc2788c389b77a26e686169 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 10:33:00 -0700 Subject: [PATCH 24/80] fix(sdk): neutralize array serialization hooks Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 20 ++++++++++--------- packages/sdk/src/json-value.ts | 5 +++++ .../tests/hosted-extension-protocol.test.ts | 19 ++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index dfd541871..1670ba2a4 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -149,14 +149,16 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms - ✓ tests/hosted-extension-routing.test.ts (2 tests) 5ms - ✓ tests/preflight.test.ts (59 tests) 110ms - ✓ tests/hosted-base-snapshot.test.ts (8 tests) 962ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 388ms - ✓ tests/authored-flow.test.ts (34 tests) 766ms - ✓ tests/hosted-extension-protocol.test.ts (20 tests) 677ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 763ms + ✓ tests/plugin-store-bounds.test.ts (3 tests) 14ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 2ms + ✓ tests/preflight.test.ts (59 tests) 107ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 326ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1035ms + ✓ tests/authored-flow.test.ts (34 tests) 752ms + ✓ tests/hosted-extension-protocol.test.ts (21 tests) 742ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 818ms Test Files 8 passed (8) - Tests 177 passed (177) + Tests 178 passed (178) + Start at 10:32:31 + Duration 1.96s (transform 1.14s, setup 0ms, collect 5.56s, tests 3.80s, environment 2ms, prepare 548ms) diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index 1a6c147e1..562d4f545 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -8,6 +8,7 @@ const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; const NUMBER_IS_INTEGER = Number.isInteger; const OBJECT_CREATE = Object.create; +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; @@ -120,6 +121,10 @@ function snapshotArray( if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); ARRAY_PUSH.call(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); } + // JSON.stringify consults `toJSON` before applying array semantics. Shadow + // any poisoned Array.prototype hook with inert, non-JSON-visible data while + // retaining the intrinsic prototype expected by downstream array consumers. + OBJECT_DEFINE_PROPERTY(out, 'toJSON', { value: undefined }); return OBJECT_FREEZE(out) as unknown as JsonValue[]; } diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 04c432d0f..d9e09707a 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -147,6 +147,25 @@ describe('hosted extension hostile protocol', () => { expect(traps).toBe(0); }); + it('shadows a poisoned Array.prototype.toJSON on copied arrays', () => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON'); + let calls = 0; + try { + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: () => { calls += 1; return 'x'.repeat(1024 * 1024); }, + }); + const snapshot = snapshotJsonValue(['safe'], 'delivery', { + maxDepth: 4, maxNodes: 4, maxBytes: 32, + }); + expect(JSON.stringify(snapshot)).toBe('["safe"]'); + expect(calls).toBe(0); + } finally { + if (previous === undefined) delete (Array.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Array.prototype, 'toJSON', previous); + } + }); + it.each(['inherited toJSON', 'stateful getter'] as const)( 'refuses a delivery descriptor with %s before the adapter', async attack => { const input = descriptor(); From d2abc3f8770e1d264ecce999872fdc619fa76b2f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 10:44:23 -0700 Subject: [PATCH 25/80] fix(sdk): capture hosted byte intrinsics Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 20 ++++++++-------- packages/sdk/src/hosted-extension-protocol.ts | 24 ++++++++++++------- packages/sdk/src/json-value.ts | 11 +++++---- .../tests/hosted-extension-protocol.test.ts | 20 ++++++++++++++++ 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 1670ba2a4..f900e543d 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -149,16 +149,16 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk - ✓ tests/plugin-store-bounds.test.ts (3 tests) 14ms - ✓ tests/hosted-extension-routing.test.ts (2 tests) 2ms - ✓ tests/preflight.test.ts (59 tests) 107ms - ✓ tests/babysitter-native-extension.test.ts (38 tests) 326ms - ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1035ms + ✓ tests/plugin-store-bounds.test.ts (3 tests) 10ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 3ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 450ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1127ms ✓ tests/authored-flow.test.ts (34 tests) 752ms - ✓ tests/hosted-extension-protocol.test.ts (21 tests) 742ms - ✓ tests/hosted-extension-isolation.test.ts (13 tests) 818ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 672ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 794ms Test Files 8 passed (8) - Tests 178 passed (178) - Start at 10:32:31 - Duration 1.96s (transform 1.14s, setup 0ms, collect 5.56s, tests 3.80s, environment 2ms, prepare 548ms) + Tests 179 passed (179) + Start at 10:43:35 + Duration 1.98s (transform 1.12s, setup 0ms, collect 5.73s, tests 3.91s, environment 1ms, prepare 444ms) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index b64de06f6..cef9231e6 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -6,10 +6,15 @@ import { PluginError } from './plugin-manifest.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const MAX_FRAME_BYTES = 256 * 1024; const MAX_STDERR_BYTES = 16 * 1024; +const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_BYTE_LENGTH = Buffer.byteLength; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; +const STRING = String; +const STRING_INDEX_OF = String.prototype.indexOf; +const STRING_SLICE = String.prototype.slice; const SNAPSHOT_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 262_144, @@ -29,7 +34,7 @@ export function boundedJsonSnapshot(value: unknown, what: string): unknown { throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); } const encoded = JSON_STRINGIFY(snapshot); - if (Buffer.byteLength(encoded) > MAX_FRAME_BYTES) { + if (BUFFER_BYTE_LENGTH(encoded) > MAX_FRAME_BYTES) { throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); } return snapshot; @@ -54,7 +59,9 @@ export async function exchangeHostedExtension( let stderrText = ''; let calls = 0; stderr.setEncoding('utf8'); - stderr.on('data', chunk => { stderrText = (stderrText + String(chunk)).slice(-MAX_STDERR_BYTES); }); + stderr.on('data', chunk => { + stderrText = STRING_SLICE.call(stderrText + STRING(chunk), -MAX_STDERR_BYTES); + }); protocol.setEncoding('utf8'); return await new Promise((resolvePromise, rejectPromise) => { @@ -97,16 +104,17 @@ export async function exchangeHostedExtension( stdin.on('error', () => refuse('Hosted extension capability channel closed.')); protocol.on('error', () => refuse('Hosted extension protocol channel failed.')); protocol.on('data', chunk => { - buffer += String(chunk); - if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); + buffer += STRING(chunk); + if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); for (;;) { - const end = buffer.indexOf('\n'); + const end = STRING_INDEX_OF.call(buffer, '\n'); if (end < 0) break; - const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + const line = STRING_SLICE.call(buffer, 0, end); + buffer = STRING_SLICE.call(buffer, end + 1); let message: Record; try { const parsed = JSON_PARSE(line) as unknown; - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + if (typeof parsed !== 'object' || parsed === null || ARRAY_IS_ARRAY(parsed)) { return refuse('Hosted extension emitted a non-object protocol frame.'); } message = parsed as Record; @@ -157,7 +165,7 @@ export async function exchangeHostedExtension( } const error = new PluginError( 'plugin_unsupported', - `Hosted extension failed: ${message.message.slice(0, 8192)}`, + `Hosted extension failed: ${STRING_SLICE.call(message.message, 0, 8192)}`, ); if (capabilityState === 'pending') { deferredProtocolError ??= error; diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index 562d4f545..f8ac62d3c 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -3,6 +3,7 @@ import { isProxy } from 'node:util/types'; const ARRAY_IS_ARRAY = Array.isArray; const ARRAY_PUSH = Array.prototype.push; const ARRAY_PROTOTYPE = Array.prototype; +const ERROR = Error; const JSON_STRINGIFY = JSON.stringify; const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; @@ -16,6 +17,8 @@ const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_PROTOTYPE = Object.prototype; const REGEXP_TEST = RegExp.prototype.test; const STRING = String; +const STRING_CHAR_CODE_AT = String.prototype.charCodeAt; +const WEAK_SET = WeakSet; const WEAK_SET_ADD = WeakSet.prototype.add; const WEAK_SET_DELETE = WeakSet.prototype.delete; const WEAK_SET_HAS = WeakSet.prototype.has; @@ -43,7 +46,7 @@ interface SnapshotBudget { /** Copy runtime input into frozen, behavior-free JSON data. */ export function snapshotJsonValue(value: unknown, at: string, limits?: JsonSnapshotLimits): JsonValue { - return snapshot(value, at, new WeakSet(), { limits, nodes: 0, properties: 0, bytes: 0 }, 0); + return snapshot(value, at, new WEAK_SET(), { limits, nodes: 0, properties: 0, bytes: 0 }, 0); } function snapshot( @@ -216,13 +219,13 @@ function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): throw nonJson(at, 'snapshot byte limit exceeded'); } for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); + const code = STRING_CHAR_CODE_AT.call(value, index); if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { consumeBytes(budget, 2, at); } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) { if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const low = value.charCodeAt(index + 1); + const low = STRING_CHAR_CODE_AT.call(value, index + 1); if (low >= 0xdc00 && low <= 0xdfff) { consumeBytes(budget, 4, at); index += 1; @@ -244,5 +247,5 @@ function consumeBytes(budget: SnapshotBudget, bytes: number, at: string): void { } function nonJson(at: string, detail: string): Error { - return new Error(`${at}: expected JSON-compatible data; ${detail}`); + return new ERROR(`${at}: expected JSON-compatible data; ${detail}`); } diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index d9e09707a..522d25c0d 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -8,6 +8,7 @@ import { runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; +import { boundedJsonSnapshot } from '../src/hosted-extension-protocol.js'; import { snapshotJsonValue } from '../src/json-value.js'; import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; import { materializePlugin } from '../src/plugin-store.js'; @@ -166,6 +167,25 @@ describe('hosted extension hostile protocol', () => { } }); + it('uses captured byte-counting intrinsics', () => { + const byteLength = Object.getOwnPropertyDescriptor(Buffer, 'byteLength')!; + const charCodeAt = Object.getOwnPropertyDescriptor(String.prototype, 'charCodeAt')!; + let calls = 0; + try { + Object.defineProperty(Buffer, 'byteLength', { + ...byteLength, value: () => { calls += 1; return 0; }, + }); + Object.defineProperty(String.prototype, 'charCodeAt', { + ...charCodeAt, value: () => { calls += 1; return 0; }, + }); + expect(boundedJsonSnapshot({ delivery: 'safe' }, 'delivery')).toEqual({ delivery: 'safe' }); + } finally { + Object.defineProperty(Buffer, 'byteLength', byteLength); + Object.defineProperty(String.prototype, 'charCodeAt', charCodeAt); + } + expect(calls).toBe(0); + }); + it.each(['inherited toJSON', 'stateful getter'] as const)( 'refuses a delivery descriptor with %s before the adapter', async attack => { const input = descriptor(); From 1cf1b7c1aaf6cf7f798f4b8ea8d98a08205f69c7 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:05:55 -0700 Subject: [PATCH 26/80] fix(sdk): capture hosted authority intrinsics Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 72 ++++++++++++++++ packages/sdk/src/flow-extension-loader.ts | 57 +++++++++---- packages/sdk/src/hosted-base-snapshot.ts | 53 ++++++++---- .../sdk/src/hosted-extension-isolation.ts | 7 +- packages/sdk/src/hosted-extension-runtime.ts | 84 ++++++++++++------- .../tests/babysitter-native-extension.test.ts | 26 ++++++ .../sdk/tests/hosted-base-snapshot.test.ts | 21 +++++ .../tests/hosted-extension-isolation.test.ts | 32 +++++++ .../tests/hosted-extension-routing.test.ts | 41 ++++++++- 9 files changed, 324 insertions(+), 69 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index f900e543d..41e7b5f84 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -162,3 +162,75 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 179 passed (179) Start at 10:43:35 Duration 1.98s (transform 1.12s, setup 0ms, collect 5.73s, tests 3.91s, environment 1ms, prepare 444ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 13ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 105ms + ✓ tests/babysitter-native-extension.test.ts (39 tests) 356ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1109ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1066ms + ✓ tests/authored-flow.test.ts (34 tests) 725ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 706ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 814ms + + Test Files 8 passed (8) + Tests 183 passed (183) + Start at 11:03:58 + Duration 2.00s (transform 1.26s, setup 0ms, collect 5.76s, tests 3.83s, environment 2ms, prepare 458ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 678ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 649ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 105ms + ✓ tests/babysitter-native-extension.test.ts (39 tests) 434ms + ✓ tests/authored-flow.test.ts (34 tests) 763ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 718ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 746ms + + Test Files 8 passed (8) + Tests 183 passed (183) + Start at 11:05:11 + Duration 2.03s (transform 1.18s, setup 0ms, collect 6.22s, tests 3.46s, environment 1ms, prepare 492ms) diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index c0f8471e3..bc12e93cd 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -10,6 +10,14 @@ import { PluginError } from './plugin-manifest.js'; import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; const JSON_PARSE = JSON.parse; +const ARRAY_IS_ARRAY = Array.isArray; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_FREEZE = Object.freeze; +const REGEXP_TEST = RegExp.prototype.test; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, + separator: string | RegExp, +) => string[]; /** * Compose schema-2 flow extensions onto a base authored flow. @@ -95,7 +103,7 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str if (trigger.kind !== 'webhook' || trigger.filter === undefined) return undefined; const { provider, type, payload } = trigger.filter as { provider?: unknown; type?: unknown; payload?: unknown }; if (typeof provider !== 'string' || provider !== trigger.name || typeof type !== 'string') return undefined; - const action = typeof payload === 'object' && payload !== null && !Array.isArray(payload) ? (payload as { action?: unknown }).action : undefined; + const action = typeof payload === 'object' && payload !== null && !ARRAY_IS_ARRAY(payload) ? (payload as { action?: unknown }).action : undefined; if (action !== undefined && typeof action !== 'string') return undefined; return action === undefined ? { provider, event: type } : { provider, event: type, action }; } @@ -122,20 +130,24 @@ const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/; const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/; function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { - if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) { + if (typeof dispatch !== 'object' || dispatch === null || ARRAY_IS_ARRAY(dispatch)) { throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.'); } const { provenance, provider, eventType, deliveryId } = dispatch as Partial; if ((dispatch as Partial)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true || provenance !== 'integration-watch' - || typeof provider !== 'string' || !DISPATCH_PROVIDER.test(provider) - || typeof deliveryId !== 'string' || !DISPATCH_DELIVERY.test(deliveryId) + || typeof provider !== 'string' || !REGEXP_TEST.call(DISPATCH_PROVIDER, provider) + || typeof deliveryId !== 'string' || !REGEXP_TEST.call(DISPATCH_DELIVERY, deliveryId) || typeof eventType !== 'string') { throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.'); } - const parts = eventType.split('.'); - if ((parts.length !== 1 && parts.length !== 2) || parts.some(part => !DISPATCH_EVENT.test(part))) { - throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON.stringify(eventType)} is malformed.`); + const parts = STRING_SPLIT(eventType, '.'); + let valid = parts.length === 1 || parts.length === 2; + for (let index = 0; valid && index < parts.length; index += 1) { + valid = REGEXP_TEST.call(DISPATCH_EVENT, parts[index]!); + } + if (!valid) { + throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON_STRINGIFY(eventType)} is malformed.`); } return parts.length === 1 ? { provider, event: parts[0]! } @@ -144,7 +156,7 @@ function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { /** Validate and project branded host authority without making it serializable. */ export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventIdentity { - return Object.freeze(hostedEventIdentity(dispatch)); + return OBJECT_FREEZE(hostedEventIdentity(dispatch)); } /** @@ -155,7 +167,7 @@ export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventI export function hostedExtensionDispatchFromVerifiedDelivery( delivery: Omit, ): HostedExtensionDispatch { - const dispatch = Object.freeze({ + const dispatch = OBJECT_FREEZE({ [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const, provenance: 'integration-watch' as const, ...delivery, @@ -177,16 +189,29 @@ export function extensionHandlerForHostedDispatch( ): { readonly extension: Pick; readonly handler: TriggerHandler } | undefined { if (dispatch === undefined) return undefined; const identity = hostedEventIdentity(dispatch); - const matches = extensions.flatMap(extension => extension.handlers.flatMap(handler => { - const subscription = subscriptionOf(handler); - if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) return []; - if (subscription.action !== undefined && subscription.action !== identity.action) return []; - return [{ extension, handler }]; - })); + const matches: Array<{ + extension: Pick; + handler: TriggerHandler; + }> = []; + for (let extensionIndex = 0; extensionIndex < extensions.length; extensionIndex += 1) { + const extension = extensions[extensionIndex]!; + for (let handlerIndex = 0; handlerIndex < extension.handlers.length; handlerIndex += 1) { + const handler = extension.handlers[handlerIndex]!; + const subscription = subscriptionOf(handler); + if (subscription === undefined || subscription.provider !== identity.provider + || subscription.event !== identity.event) continue; + if (subscription.action !== undefined && subscription.action !== identity.action) continue; + matches[matches.length] = { extension, handler }; + } + } if (matches.length > 1) { + let matchingNames = ''; + for (let index = 0; index < matches.length; index += 1) { + matchingNames += `${index === 0 ? '' : ', '}${matches[index]!.extension.name}`; + } throw new PluginError( 'plugin_event_ambiguous', - `Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matches.map(match => match.extension.name).join(', ')}).`, + `Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matchingNames}).`, ); } return matches[0]; diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index adc49a096..cd16c92c5 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -8,6 +8,12 @@ import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); +const ARRAY_PUSH = Array.prototype.push; +const ARRAY_SORT = Array.prototype.sort; +const OBJECT_FREEZE = Object.freeze; +const SET_HAS = Set.prototype.has; +const STRING_LOCALE_COMPARE = String.prototype.localeCompare; +const STRING_STARTS_WITH = String.prototype.startsWith; const MAX_ENTRIES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; const MAX_DEPTH = 64; @@ -53,18 +59,19 @@ export async function createHostedBaseSnapshot(flowPath: string): Promise { const budget: SourceBudget = { entries: 0, bytes: 0 }; const files: SourceFile[] = []; - for (const source of sources) files.push(...await readTree(source.root, source.prefix, budget, hooks)); - files.sort((left, right) => left.path.localeCompare(right.path)); - return Object.freeze(files); + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { + const source = sources[sourceIndex]!; + const sourceFiles = await readTree(source.root, source.prefix, budget, hooks); + for (let fileIndex = 0; fileIndex < sourceFiles.length; fileIndex += 1) { + ARRAY_PUSH.call(files, sourceFiles[fileIndex]!); + } + } + ARRAY_SORT.call(files, (left, right) => STRING_LOCALE_COMPARE.call(left.path, right.path)); + return OBJECT_FREEZE(files); } async function readSnapshotTree(root: string): Promise { const files: SourceFile[] = []; async function visit(directory: string, prefix: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { + ARRAY_SORT.call(entries, (left, right) => STRING_LOCALE_COMPARE.call(left.name, right.name)); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; const path = prefix === '' ? entry.name : join(prefix, entry.name); const absolute = join(directory, entry.name); if (entry.isDirectory()) await visit(absolute, path); else if (entry.isFile()) { const bytes = await readFile(absolute); - files.push(Object.freeze({ path, bytes, sha256: sha256(bytes) })); + ARRAY_PUSH.call(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); } } await visit(root, ''); - files.sort((left, right) => left.path.localeCompare(right.path)); - return Object.freeze(files); + ARRAY_SORT.call(files, (left, right) => STRING_LOCALE_COMPARE.call(left.path, right.path)); + return OBJECT_FREEZE(files); } async function readTree( @@ -146,7 +160,7 @@ async function readTree( if (depth > MAX_DEPTH) throw tooLarge(); const entries = await opendir(`/proc/self/fd/${directory.fd}`); for await (const entry of entries) { - if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; + if (SET_HAS.call(EXCLUDED_DIRECTORIES, entry.name)) continue; budget.entries += 1; if (budget.entries > MAX_ENTRIES) throw tooLarge(); const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); @@ -167,7 +181,7 @@ async function readTree( throw invalid(`Hosted base source changed while reading "${relativePath}".`); } budget.bytes += bytes.byteLength; - files.push(Object.freeze({ + ARRAY_PUSH.call(files, OBJECT_FREEZE({ path: prefix === '' ? relativePath : join(prefix, relativePath), bytes, sha256: sha256(bytes), @@ -194,7 +208,7 @@ async function readTree( if (error instanceof PluginError) throw error; throw invalid('Hosted base source or trusted dependency is unreadable.'); } - return Object.freeze(files); + return OBJECT_FREEZE(files); } async function readBounded( @@ -223,7 +237,12 @@ function tooLarge(): PluginError { } function sourceDigest(files: readonly SourceFile[]): string { - return sha256(canonicalize(files.map(file => ({ path: file.path, sha256: file.sha256 })))); + const records: Array<{ path: string; sha256: string }> = []; + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + records[index] = { path: file.path, sha256: file.sha256 }; + } + return sha256(canonicalize(records)); } function sha256(value: Uint8Array | string): string { diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index c0d4dd8ef..c7278e31e 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -144,9 +144,9 @@ export async function runVerifiedNativeExtensionSandbox( assertManifestRoutes(options.manifest, identity); const normalizedInput = babysitterInput(options.input, options.dispatch); const versions = runtimeVersions(); - const authority: HostedCapabilityAuthority = Object.freeze({ + const authority: HostedCapabilityAuthority = OBJECT_FREEZE({ dispatch: options.dispatch, - extension: Object.freeze({ + extension: OBJECT_FREEZE({ name: options.manifest.name, version: options.manifest.version, ref: options.artifact.ref, @@ -186,7 +186,8 @@ export async function selectHostedExtensionForRuntime( throw new PluginError('plugin_incompatible', 'Hosted base identity is malformed.'); } const matches: Array<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> = []; - for (const artifact of value.artifacts) { + for (let index = 0; index < value.artifacts.length; index += 1) { + const artifact = value.artifacts[index]!; const manifest = await verifiedManifest(artifact); assertCompatible(manifest, versions); assertBaseCompatible(manifest, base); diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 5e296da61..6889c11a7 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -28,6 +28,12 @@ const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); const JSON_PARSE = JSON.parse; +const ARRAY_IS_ARRAY = Array.isArray; +const OBJECT_FREEZE = Object.freeze; +const WEAK_MAP_GET = WeakMap.prototype.get; +const WEAK_MAP_SET = WeakMap.prototype.set; +const WEAK_SET_ADD = WeakSet.prototype.add; +const WEAK_SET_HAS = WeakSet.prototype.has; interface RuntimeGeneration { readonly origin: string; @@ -76,8 +82,8 @@ export async function loadHostedExtensionRuntime(flowPath: string): Promise { - if (typeof base !== 'object' || base === null || !BASE_AUTHORITY.has(base)) { + if (typeof base !== 'object' || base === null || !WEAK_SET_HAS.call(BASE_AUTHORITY, base)) { throw new PluginError( 'plugin_incompatible', 'Hosted extension base authority is malformed; use loadHostedExtensionRuntime.', ); } if (typeof installation !== 'object' || installation === null - || !INSTALLATION_AUTHORITY.has(installation)) { + || !WEAK_SET_HAS.call(INSTALLATION_AUTHORITY, installation)) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension installation authority is malformed; use loadHostedExtensionRuntime.', ); } - const generation = BASE_GENERATION.get(base); - if (generation === undefined || generation !== INSTALLATION_GENERATION.get(installation)) { + const generation = WEAK_MAP_GET.call(BASE_GENERATION, base); + if (generation === undefined || generation !== WEAK_MAP_GET.call(INSTALLATION_GENERATION, installation)) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension base and installation must originate from the same runtime generation.', @@ -129,8 +135,8 @@ export async function assertHostedRuntimeAuthority( /** @internal Validate a metadata-only installation used by selection tests. */ export function assertHostedInstallationAuthority(value: unknown): asserts value is HostedExtensionInstallation { - if (typeof value !== 'object' || value === null || !INSTALLATION_AUTHORITY.has(value) - || !Array.isArray((value as Partial).artifacts)) { + if (typeof value !== 'object' || value === null || !WEAK_SET_HAS.call(INSTALLATION_AUTHORITY, value) + || !ARRAY_IS_ARRAY((value as Partial).artifacts)) { throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); } } @@ -145,17 +151,18 @@ async function installationAt( }; const artifacts: HostedExtensionArtifact[] = []; const declared = hostedDeclaredExtensions(root); - for (const { ref, entry } of declared) { + for (let index = 0; index < declared.length; index += 1) { + const { ref, entry } = declared[index]!; const directory = pluginStoreDirectory(root, entry.name, entry.digest); await verifyStoredPlugin(directory, entry.digest); - artifacts.push(Object.freeze({ + artifacts[artifacts.length] = OBJECT_FREEZE({ ref, name: entry.name, version: entry.version, directory, digest: entry.digest, manifestSha256: entry.manifestSha256, - })); + }); } return { installation: installation(artifacts, generation), @@ -175,13 +182,24 @@ function declaredExtensions(origin: string): { readonly signature: string } { function declarationSignature( declared: ReturnType, ): string { - return canonicalize(declared.map(({ ref, entry }) => ({ - ref, - name: entry.name, - version: entry.version, - digest: entry.digest, - manifestSha256: entry.manifestSha256, - }))); + const records: Array<{ + ref: string; + name: string; + version: string; + digest: string; + manifestSha256: string; + }> = []; + for (let index = 0; index < declared.length; index += 1) { + const { ref, entry } = declared[index]!; + records[index] = { + ref, + name: entry.name, + version: entry.version, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + }; + } + return canonicalize(records); } function hostedDeclaredExtensions( @@ -196,11 +214,11 @@ function hostedDeclaredExtensions( if (error instanceof PluginError) throw error; throw new PluginError('plugin_manifest_invalid', 'Invalid or oversized flows.json.'); } - if (typeof config !== 'object' || config === null || Array.isArray(config)) { + if (typeof config !== 'object' || config === null || ARRAY_IS_ARRAY(config)) { throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); } const plugins = (config as { plugins?: unknown }).plugins; - if (plugins !== undefined && !Array.isArray(plugins)) { + if (plugins !== undefined && !ARRAY_IS_ARRAY(plugins)) { throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); } const declared: string[] = []; @@ -213,9 +231,9 @@ function hostedDeclaredExtensions( if (isGithubPluginRef(ref)) declared[declared.length] = ref; } } - let lock: ReturnType = Object.freeze({ + let lock: ReturnType = OBJECT_FREEZE({ version: PLUGIN_LOCK_VERSION, - plugins: Object.freeze([]), + plugins: OBJECT_FREEZE([]), }); const lockPath = join(root, PLUGIN_LOCK_FILE); if (existsSync(lockPath)) { @@ -233,14 +251,14 @@ function hostedDeclaredExtensions( const result: Array<{ ref: string; entry: PluginLockEntry; source: PluginSourceRef }> = []; for (let index = 0; index < lock.plugins.length; index += 1) { const entry = lock.plugins[index]!; - const source = Object.freeze({ ...entry.source, ref: entry.source.sha }); + const source = OBJECT_FREEZE({ ...entry.source, ref: entry.source.sha }); const ref = canonicalPluginRef(source); if (declared[index] !== ref) { throw new PluginError('plugin_lock_invalid', 'Hosted flows.lock.json order differs from flows.json.plugins.'); } - result[result.length] = Object.freeze({ ref, entry, source }); + result[result.length] = OBJECT_FREEZE({ ref, entry, source }); } - return Object.freeze(result); + return OBJECT_FREEZE(result); } function readBoundedDeclaration(path: string): Buffer { @@ -277,7 +295,7 @@ function newGeneration(origin: string): RuntimeGeneration { return { origin, declarations: canonicalize([]), - sourceRoots: Object.freeze([]), + sourceRoots: OBJECT_FREEZE([]), sourceSha256: '', }; } @@ -312,9 +330,9 @@ async function baseAt( 'Hosted capability isolation accepts only the reviewed Software Factory base source.', ); } - const value = Object.freeze({ name: 'software-factory', version: '2.0.22' }); - BASE_AUTHORITY.add(value); - BASE_GENERATION.set(value, generation); + const value = OBJECT_FREEZE({ name: 'software-factory', version: '2.0.22' }); + WEAK_SET_ADD.call(BASE_AUTHORITY, value); + WEAK_MAP_SET.call(BASE_GENERATION, value, generation); return value; } finally { await removeHostedBaseSnapshot(snapshot); @@ -325,8 +343,10 @@ function installation( artifacts: readonly HostedExtensionArtifact[], generation: RuntimeGeneration, ): HostedExtensionInstallation { - const value = Object.freeze({ artifacts: Object.freeze([...artifacts]) }); - INSTALLATION_AUTHORITY.add(value); - INSTALLATION_GENERATION.set(value, generation); + const artifactCopy: HostedExtensionArtifact[] = []; + for (let index = 0; index < artifacts.length; index += 1) artifactCopy[index] = artifacts[index]!; + const value = OBJECT_FREEZE({ artifacts: OBJECT_FREEZE(artifactCopy) }); + WEAK_SET_ADD.call(INSTALLATION_AUTHORITY, value); + WEAK_MAP_SET.call(INSTALLATION_GENERATION, value, generation); return value; } diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 5a7db8a3d..9e69707d0 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -15,6 +15,7 @@ import { runHostedCapabilityExtension, selectHostedExtensionForRuntime, } from '../src/hosted-extension-isolation.js'; +import { assertHostedRuntimeAuthority } from '../src/hosted-extension-runtime.js'; import { JournalClient } from '../src/journal-client.js'; import { materializePlugin } from '../src/plugin-store.js'; import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; @@ -225,6 +226,31 @@ describe('native Babysitter extension', () => { expect(calls).toBe(0); }); + it('validates opaque runtime authority with captured WeakSet and WeakMap methods', async () => { + const originalHas = WeakSet.prototype.has; + const originalGet = WeakMap.prototype.get; + let poisonCalls = 0; + let failure: unknown; + try { + WeakSet.prototype.has = (() => { poisonCalls += 1; return true; }) as typeof WeakSet.prototype.has; + WeakMap.prototype.get = (() => { poisonCalls += 1; return undefined; }) as typeof WeakMap.prototype.get; + await assertHostedRuntimeAuthority( + installed.hostedRuntime.installation, + installed.hostedRuntime.base, + ); + try { + await assertHostedRuntimeAuthority({ artifacts: [] } as never, { name: 'software-factory' } as never); + } catch (error) { + failure = error; + } + } finally { + WeakSet.prototype.has = originalHas; + WeakMap.prototype.get = originalGet; + } + expect(poisonCalls).toBe(0); + expect(failure).toMatchObject({ code: 'plugin_incompatible' }); + }); + it('refuses stale installation authority after the same flow path is redeployed', async () => { const configPath = join(installed.cwd, 'flows.json'); const lockPath = join(installed.cwd, 'flows.lock.json'); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 78db759e9..e60cd18e7 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -37,6 +37,27 @@ describe('hosted base private snapshot', () => { } }); + it('keeps source digests sensitive when ambient Array.map is poisoned', async () => { + const { project } = fixture(); + const sources = [{ root: project, prefix: '' }]; + const before = await hostedBaseSourceDigest(sources); + writeFileSync(join(project, 'helper.ts'), `export const identity = 'changed';\n`); + const originalMap = Array.prototype.map; + let poisonCalls = 0; + let after: string | undefined; + try { + Array.prototype.map = function poisonedMap() { + poisonCalls += 1; + return []; + } as typeof Array.prototype.map; + after = await hostedBaseSourceDigest(sources); + } finally { + Array.prototype.map = originalMap; + } + expect(poisonCalls).toBe(0); + expect(after).not.toBe(before); + }); + it('excludes project node_modules from the admitted generation', async () => { const { project, flowPath } = fixture(); const dependency = join(project, 'node_modules/local-identity'); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 60ff82b08..a6d61551b 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -436,6 +436,38 @@ export default flow('babysitter', async f => f.done('declined')) expect(() => readFileSync(marker)).toThrow(); }); + it('constructs adapter authority with the captured freeze intrinsic', async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-freeze', + }); + const validatedManifest = validateFlowExtensionManifest(manifest()); + const originalFreeze = Object.freeze; + let poisonCalls = 0; + let result: Awaited> | undefined; + try { + Object.freeze = ((value: object) => { + if ('dispatch' in value || ('ref' in value && 'digest' in value && 'version' in value)) { + poisonCalls += 1; + throw new Error('ambient authority freeze'); + } + return originalFreeze(value); + }) as typeof Object.freeze; + result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validatedManifest, + dispatch, + input: descriptor('delivery-freeze'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-freeze', status: 'queued' }) }, + timeoutMs: 3_000, + }); + } finally { + Object.freeze = originalFreeze; + } + expect(poisonCalls).toBe(0); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + }); + it('fails closed when the handler omits or repeats the single capability call', async () => { for (const body of [ `f.done('success')`, diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index d0aa40246..de11c9da1 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import { github } from '@relayflows/surface'; -import { extensionHandlerForHostedDispatch } from '../src/flow-extension-loader.js'; +import { + extensionHandlerForHostedDispatch, + hostedExtensionDispatchIdentity, +} from '../src/flow-extension-loader.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; import { supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; @@ -44,4 +47,40 @@ describe('hosted extension routing policy', () => { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); }); + + it('parses and routes dispatch authority with captured intrinsics', () => { + const body = async () => {}; + const extension = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const originals = { + freeze: Object.freeze, + split: String.prototype.split, + test: RegExp.prototype.test, + some: Array.prototype.some, + flatMap: Array.prototype.flatMap, + }; + let poisonCalls = 0; + let identity: ReturnType | undefined; + let selected: ReturnType; + try { + Object.freeze = (() => { poisonCalls += 1; throw new Error('ambient freeze'); }) as typeof Object.freeze; + String.prototype.split = (() => { poisonCalls += 1; return ['forged']; }) as typeof String.prototype.split; + RegExp.prototype.test = (() => { poisonCalls += 1; return false; }) as typeof RegExp.prototype.test; + Array.prototype.some = (() => { poisonCalls += 1; return true; }) as typeof Array.prototype.some; + Array.prototype.flatMap = (() => { poisonCalls += 1; return []; }) as typeof Array.prototype.flatMap; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-poison', + }); + identity = hostedExtensionDispatchIdentity(dispatch); + selected = extensionHandlerForHostedDispatch(dispatch, [extension]); + } finally { + Object.freeze = originals.freeze; + String.prototype.split = originals.split; + RegExp.prototype.test = originals.test; + Array.prototype.some = originals.some; + Array.prototype.flatMap = originals.flatMap; + } + expect(poisonCalls).toBe(0); + expect(identity).toEqual({ provider: 'github', event: 'pull_request', action: 'labeled' }); + expect(selected?.extension).toBe(extension); + }); }); From 81bf2a668894314c4cfdb946a5aa6887b274c71c Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:17:01 -0700 Subject: [PATCH 27/80] fix(sdk): capture hosted hash methods Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 +++++++++++++++++++ packages/sdk/src/bundle.ts | 14 +++++++- packages/sdk/src/hosted-base-snapshot.ts | 6 +--- .../sdk/src/hosted-extension-isolation.ts | 6 +--- packages/sdk/src/hosted-extension-runtime.ts | 4 +-- packages/sdk/src/hosted-extension-sandbox.ts | 5 +-- .../tests/babysitter-native-extension.test.ts | 25 +++++++++++++ 7 files changed, 79 insertions(+), 17 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 41e7b5f84..5d92b625a 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -234,3 +234,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 183 passed (183) Start at 11:05:11 Duration 2.03s (transform 1.18s, setup 0ms, collect 6.22s, tests 3.46s, environment 1ms, prepare 492ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 3ms + ✓ tests/preflight.test.ts (59 tests) 114ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1021ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 982ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 520ms + ✓ tests/authored-flow.test.ts (34 tests) 768ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 654ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 795ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:16:16 + Duration 2.00s (transform 1.19s, setup 0ms, collect 5.81s, tests 3.88s, environment 1ms, prepare 407ms) diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index bbf986783..1e8a529f6 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -3,6 +3,16 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from import { basename, dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; +type Hash = ReturnType; +const HASH_UPDATE = Function.prototype.call.bind(createHash('sha256').update) as ( + hash: Hash, + data: Uint8Array | string, +) => Hash; +const HASH_DIGEST = Function.prototype.call.bind(createHash('sha256').digest) as ( + hash: Hash, + encoding: 'hex', +) => string; + export interface BundleEntry { path: string; sha256: string; bytes: number } export interface BundleFile { path: string; data: Uint8Array | string; executable?: boolean } export interface BundleOptions { @@ -15,7 +25,9 @@ export interface BundleOptions { } export function sha256(data: Uint8Array | string): string { - return createHash('sha256').update(data).digest('hex'); + const hash = createHash('sha256'); + HASH_UPDATE(hash, data); + return HASH_DIGEST(hash, 'hex'); } /** A bundle-relative path: no empty, `.`, or `..` component, no backslash, NUL, or drive colon. */ diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index cd16c92c5..3f4514a0a 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,9 +1,9 @@ -import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; import { chmod, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; @@ -245,10 +245,6 @@ function sourceDigest(files: readonly SourceFile[]): string { return sha256(canonicalize(records)); } -function sha256(value: Uint8Array | string): string { - return createHash('sha256').update(value).digest('hex'); -} - function invalid(message: string): PluginError { return new PluginError('plugin_source_invalid', message); } diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index c7278e31e..9f73aaad3 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto'; import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; import { @@ -432,7 +432,3 @@ function manifestNames( } return value; } - -function sha256(value: Uint8Array | string): string { - return createHash('sha256').update(value).digest('hex'); -} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 6889c11a7..689e981df 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,8 +1,8 @@ -import { createHash } from 'node:crypto'; import { constants, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'; import { readFile, realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; import { createHostedBaseSnapshot, hostedBaseSourceDigest, @@ -324,7 +324,7 @@ async function baseAt( generation.sourceSha256 = snapshot.liveDigest; try { const baseBytes = await readFile(snapshot.snapshotFlowPath); - if (createHash('sha256').update(baseBytes).digest('hex') !== SOFTWARE_FACTORY_SHA256) { + if (sha256(baseBytes) !== SOFTWARE_FACTORY_SHA256) { throw new PluginError( 'plugin_source_invalid', 'Hosted capability isolation accepts only the reviewed Software Factory base source.', diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 56c7c1692..99d9585c4 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; @@ -6,6 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, parse, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; +import { sha256 } from './bundle.js'; import { PluginError } from './plugin-manifest.js'; import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; import { @@ -229,9 +229,6 @@ function surfaceRuntimeMounts(surfaceFacade: string): string[] { ]); } -function sha256(bytes: Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} function executable(path: string, name: string): string { let real: string; diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 9e69707d0..7f1220718 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -251,6 +251,31 @@ describe('native Babysitter extension', () => { expect(failure).toMatchObject({ code: 'plugin_incompatible' }); }); + it('checks the reviewed base pin with captured hash methods', async () => { + const racing = await composed(); + writeFileSync(racing.flowPath, `export default {};\n`); + const prototype = Object.getPrototypeOf(createHash('sha256')) as { + update: typeof import('node:crypto').Hash.prototype.update; + digest: typeof import('node:crypto').Hash.prototype.digest; + }; + const originalUpdate = prototype.update; + const originalDigest = prototype.digest; + let poisonCalls = 0; + try { + prototype.update = function poisonedUpdate() { poisonCalls += 1; return this; } as typeof prototype.update; + prototype.digest = (() => { + poisonCalls += 1; + return '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; + }) as typeof prototype.digest; + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + prototype.update = originalUpdate; + prototype.digest = originalDigest; + } + expect(poisonCalls).toBe(0); + }); + it('refuses stale installation authority after the same flow path is redeployed', async () => { const configPath = join(installed.cwd, 'flows.json'); const lockPath = join(installed.cwd, 'flows.lock.json'); From 4131f145102bcdbe039d19dc852693682c6759e9 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:28:51 -0700 Subject: [PATCH 28/80] fix(sdk): bind hosted authority intrinsics Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 +++++++++++++++++++ packages/sdk/src/canonical.ts | 22 +++++++----- packages/sdk/src/flow-extension-loader.ts | 10 +++--- packages/sdk/src/hosted-base-snapshot.ts | 32 ++++++++++------- .../sdk/src/hosted-extension-isolation.ts | 12 ++++--- packages/sdk/src/hosted-extension-protocol.ts | 18 ++++++---- packages/sdk/src/hosted-extension-runtime.ts | 34 +++++++++++------- packages/sdk/src/json-value.ts | 36 ++++++++++++------- .../tests/babysitter-native-extension.test.ts | 11 ++++++ 9 files changed, 148 insertions(+), 63 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 5d92b625a..982b09ae0 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -270,3 +270,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 184 passed (184) Start at 11:16:16 Duration 2.00s (transform 1.19s, setup 0ms, collect 5.81s, tests 3.88s, environment 1ms, prepare 407ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 104ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1051ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1012ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 517ms + ✓ tests/authored-flow.test.ts (34 tests) 746ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 642ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 787ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:28:02 + Duration 1.99s (transform 1.22s, setup 0ms, collect 5.83s, tests 3.86s, environment 1ms, prepare 484ms) diff --git a/packages/sdk/src/canonical.ts b/packages/sdk/src/canonical.ts index 4b13f2ff0..16072cc8e 100644 --- a/packages/sdk/src/canonical.ts +++ b/packages/sdk/src/canonical.ts @@ -11,10 +11,11 @@ import { createHash } from 'node:crypto'; import { snapshotJsonValue, type JsonValue } from './json-value.js'; const ARRAY_IS_ARRAY = Array.isArray; -const ARRAY_JOIN = Array.prototype.join; -const ARRAY_MAP = Array.prototype.map; -const ARRAY_PUSH = Array.prototype.push; -const ARRAY_SORT = Array.prototype.sort; +const ARRAY_JOIN = Function.prototype.call.bind(Array.prototype.join) as ( + array: readonly string[], separator?: string, +) => string; +const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; +const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as (array: T[]) => T[]; const JSON_STRINGIFY = JSON.stringify; const OBJECT_KEYS = Object.keys; @@ -53,15 +54,18 @@ function serialize(value: JsonValue): string { return JSON_STRINGIFY(value); } if (ARRAY_IS_ARRAY(value)) { - return '[' + ARRAY_JOIN.call(ARRAY_MAP.call(value, serialize), ',') + ']'; + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) items[index] = serialize(value[index]!); + return '[' + ARRAY_JOIN(items, ',') + ']'; } const parts: string[] = []; const keys = OBJECT_KEYS(value); - ARRAY_SORT.call(keys); - for (const key of keys) { + ARRAY_SORT(keys); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; const child = value[key]; if (child === undefined) continue; - ARRAY_PUSH.call(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); + ARRAY_PUSH(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); } - return '{' + ARRAY_JOIN.call(parts, ',') + '}'; + return '{' + ARRAY_JOIN(parts, ',') + '}'; } diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index bc12e93cd..8bf02fb49 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -13,7 +13,9 @@ const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; const JSON_STRINGIFY = JSON.stringify; const OBJECT_FREEZE = Object.freeze; -const REGEXP_TEST = RegExp.prototype.test; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( value: string, separator: string | RegExp, @@ -136,15 +138,15 @@ function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { const { provenance, provider, eventType, deliveryId } = dispatch as Partial; if ((dispatch as Partial)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true || provenance !== 'integration-watch' - || typeof provider !== 'string' || !REGEXP_TEST.call(DISPATCH_PROVIDER, provider) - || typeof deliveryId !== 'string' || !REGEXP_TEST.call(DISPATCH_DELIVERY, deliveryId) + || typeof provider !== 'string' || !REGEXP_TEST(DISPATCH_PROVIDER, provider) + || typeof deliveryId !== 'string' || !REGEXP_TEST(DISPATCH_DELIVERY, deliveryId) || typeof eventType !== 'string') { throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.'); } const parts = STRING_SPLIT(eventType, '.'); let valid = parts.length === 1 || parts.length === 2; for (let index = 0; valid && index < parts.length; index += 1) { - valid = REGEXP_TEST.call(DISPATCH_EVENT, parts[index]!); + valid = REGEXP_TEST(DISPATCH_EVENT, parts[index]!); } if (!valid) { throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON_STRINGIFY(eventType)} is malformed.`); diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 3f4514a0a..c28a39a3a 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -8,12 +8,18 @@ import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); -const ARRAY_PUSH = Array.prototype.push; -const ARRAY_SORT = Array.prototype.sort; +const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; +const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( + array: T[], compare?: (left: T, right: T) => number, +) => T[]; const OBJECT_FREEZE = Object.freeze; -const SET_HAS = Set.prototype.has; -const STRING_LOCALE_COMPARE = String.prototype.localeCompare; -const STRING_STARTS_WITH = String.prototype.startsWith; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING_LOCALE_COMPARE = Function.prototype.call.bind(String.prototype.localeCompare) as ( + value: string, other: string, +) => number; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; const MAX_ENTRIES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; const MAX_DEPTH = 64; @@ -59,7 +65,7 @@ export async function createHostedBaseSnapshot(flowPath: string): Promise STRING_LOCALE_COMPARE.call(left.path, right.path)); + ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); return OBJECT_FREEZE(files); } @@ -125,7 +131,7 @@ async function readSnapshotTree(root: string): Promise { const files: SourceFile[] = []; async function visit(directory: string, prefix: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); - ARRAY_SORT.call(entries, (left, right) => STRING_LOCALE_COMPARE.call(left.name, right.name)); + ARRAY_SORT(entries, (left, right) => STRING_LOCALE_COMPARE(left.name, right.name)); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; const path = prefix === '' ? entry.name : join(prefix, entry.name); @@ -133,12 +139,12 @@ async function readSnapshotTree(root: string): Promise { if (entry.isDirectory()) await visit(absolute, path); else if (entry.isFile()) { const bytes = await readFile(absolute); - ARRAY_PUSH.call(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); + ARRAY_PUSH(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); } } await visit(root, ''); - ARRAY_SORT.call(files, (left, right) => STRING_LOCALE_COMPARE.call(left.path, right.path)); + ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); return OBJECT_FREEZE(files); } @@ -160,7 +166,7 @@ async function readTree( if (depth > MAX_DEPTH) throw tooLarge(); const entries = await opendir(`/proc/self/fd/${directory.fd}`); for await (const entry of entries) { - if (SET_HAS.call(EXCLUDED_DIRECTORIES, entry.name)) continue; + if (SET_HAS(EXCLUDED_DIRECTORIES, entry.name)) continue; budget.entries += 1; if (budget.entries > MAX_ENTRIES) throw tooLarge(); const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); @@ -181,7 +187,7 @@ async function readTree( throw invalid(`Hosted base source changed while reading "${relativePath}".`); } budget.bytes += bytes.byteLength; - ARRAY_PUSH.call(files, OBJECT_FREEZE({ + ARRAY_PUSH(files, OBJECT_FREEZE({ path: prefix === '' ? relativePath : join(prefix, relativePath), bytes, sha256: sha256(bytes), diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 9f73aaad3..1e0923009 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -40,8 +40,12 @@ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_FREEZE = Object.freeze; const OBJECT_KEYS = Object.keys; -const REGEXP_TEST = RegExp.prototype.test; -const STRING_STARTS_WITH = String.prototype.startsWith; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; const BABYSITTER_REF = 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter'; const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; @@ -247,7 +251,7 @@ async function assertPinnedBabysitter(artifact: HostedExtensionArtifact): Promis const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); for (let index = 0; index < stored.length; index += 1) { const path = stored[index]!.path; - if (path === 'node_modules' || STRING_STARTS_WITH.call(path, 'node_modules/')) { + if (path === 'node_modules' || STRING_STARTS_WITH(path, 'node_modules/')) { throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); } } @@ -369,7 +373,7 @@ function babysitterReceipt(value: unknown): unknown { } function matches(pattern: RegExp, value: string): boolean { - return REGEXP_TEST.call(pattern, value); + return REGEXP_TEST(pattern, value); } function record(value: unknown, what: string): Record { diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index cef9231e6..140d9662f 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -13,8 +13,12 @@ const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; const STRING = String; -const STRING_INDEX_OF = String.prototype.indexOf; -const STRING_SLICE = String.prototype.slice; +const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) as ( + value: string, search: string, +) => number; +const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as ( + value: string, start?: number, end?: number, +) => string; const SNAPSHOT_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 262_144, @@ -60,7 +64,7 @@ export async function exchangeHostedExtension( let calls = 0; stderr.setEncoding('utf8'); stderr.on('data', chunk => { - stderrText = STRING_SLICE.call(stderrText + STRING(chunk), -MAX_STDERR_BYTES); + stderrText = STRING_SLICE(stderrText + STRING(chunk), -MAX_STDERR_BYTES); }); protocol.setEncoding('utf8'); @@ -107,10 +111,10 @@ export async function exchangeHostedExtension( buffer += STRING(chunk); if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); for (;;) { - const end = STRING_INDEX_OF.call(buffer, '\n'); + const end = STRING_INDEX_OF(buffer, '\n'); if (end < 0) break; - const line = STRING_SLICE.call(buffer, 0, end); - buffer = STRING_SLICE.call(buffer, end + 1); + const line = STRING_SLICE(buffer, 0, end); + buffer = STRING_SLICE(buffer, end + 1); let message: Record; try { const parsed = JSON_PARSE(line) as unknown; @@ -165,7 +169,7 @@ export async function exchangeHostedExtension( } const error = new PluginError( 'plugin_unsupported', - `Hosted extension failed: ${STRING_SLICE.call(message.message, 0, 8192)}`, + `Hosted extension failed: ${STRING_SLICE(message.message, 0, 8192)}`, ); if (capabilityState === 'pending') { deferredProtocolError ??= error; diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 689e981df..f1fe747bf 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -30,10 +30,18 @@ const DECLARATION_READ_FLAGS = constants.O_RDONLY const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; -const WEAK_MAP_GET = WeakMap.prototype.get; -const WEAK_MAP_SET = WeakMap.prototype.set; -const WEAK_SET_ADD = WeakSet.prototype.add; -const WEAK_SET_HAS = WeakSet.prototype.has; +const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( + map: WeakMap, key: K, +) => V | undefined; +const WEAK_MAP_SET = Function.prototype.call.bind(WeakMap.prototype.set) as ( + map: WeakMap, key: K, value: V, +) => WeakMap; +const WEAK_SET_ADD = Function.prototype.call.bind(WeakSet.prototype.add) as ( + set: WeakSet, value: T, +) => WeakSet; +const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as ( + set: WeakSet, value: T, +) => boolean; interface RuntimeGeneration { readonly origin: string; @@ -110,21 +118,21 @@ export async function assertHostedRuntimeAuthority( installation: HostedExtensionInstallation, base: HostedExtensionBase, ): Promise { - if (typeof base !== 'object' || base === null || !WEAK_SET_HAS.call(BASE_AUTHORITY, base)) { + if (typeof base !== 'object' || base === null || !WEAK_SET_HAS(BASE_AUTHORITY, base)) { throw new PluginError( 'plugin_incompatible', 'Hosted extension base authority is malformed; use loadHostedExtensionRuntime.', ); } if (typeof installation !== 'object' || installation === null - || !WEAK_SET_HAS.call(INSTALLATION_AUTHORITY, installation)) { + || !WEAK_SET_HAS(INSTALLATION_AUTHORITY, installation)) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension installation authority is malformed; use loadHostedExtensionRuntime.', ); } - const generation = WEAK_MAP_GET.call(BASE_GENERATION, base); - if (generation === undefined || generation !== WEAK_MAP_GET.call(INSTALLATION_GENERATION, installation)) { + const generation = WEAK_MAP_GET(BASE_GENERATION, base); + if (generation === undefined || generation !== WEAK_MAP_GET(INSTALLATION_GENERATION, installation)) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension base and installation must originate from the same runtime generation.', @@ -135,7 +143,7 @@ export async function assertHostedRuntimeAuthority( /** @internal Validate a metadata-only installation used by selection tests. */ export function assertHostedInstallationAuthority(value: unknown): asserts value is HostedExtensionInstallation { - if (typeof value !== 'object' || value === null || !WEAK_SET_HAS.call(INSTALLATION_AUTHORITY, value) + if (typeof value !== 'object' || value === null || !WEAK_SET_HAS(INSTALLATION_AUTHORITY, value) || !ARRAY_IS_ARRAY((value as Partial).artifacts)) { throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); } @@ -331,8 +339,8 @@ async function baseAt( ); } const value = OBJECT_FREEZE({ name: 'software-factory', version: '2.0.22' }); - WEAK_SET_ADD.call(BASE_AUTHORITY, value); - WEAK_MAP_SET.call(BASE_GENERATION, value, generation); + WEAK_SET_ADD(BASE_AUTHORITY, value); + WEAK_MAP_SET(BASE_GENERATION, value, generation); return value; } finally { await removeHostedBaseSnapshot(snapshot); @@ -346,7 +354,7 @@ function installation( const artifactCopy: HostedExtensionArtifact[] = []; for (let index = 0; index < artifacts.length; index += 1) artifactCopy[index] = artifacts[index]!; const value = OBJECT_FREEZE({ artifacts: OBJECT_FREEZE(artifactCopy) }); - WEAK_SET_ADD.call(INSTALLATION_AUTHORITY, value); - WEAK_MAP_SET.call(INSTALLATION_GENERATION, value, generation); + WEAK_SET_ADD(INSTALLATION_AUTHORITY, value); + WEAK_MAP_SET(INSTALLATION_GENERATION, value, generation); return value; } diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index f8ac62d3c..976ed2123 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -1,7 +1,7 @@ import { isProxy } from 'node:util/types'; const ARRAY_IS_ARRAY = Array.isArray; -const ARRAY_PUSH = Array.prototype.push; +const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_PROTOTYPE = Array.prototype; const ERROR = Error; const JSON_STRINGIFY = JSON.stringify; @@ -15,13 +15,23 @@ const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_PROTOTYPE = Object.prototype; -const REGEXP_TEST = RegExp.prototype.test; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; const STRING = String; -const STRING_CHAR_CODE_AT = String.prototype.charCodeAt; +const STRING_CHAR_CODE_AT = Function.prototype.call.bind(String.prototype.charCodeAt) as ( + value: string, index: number, +) => number; const WEAK_SET = WeakSet; -const WEAK_SET_ADD = WeakSet.prototype.add; -const WEAK_SET_DELETE = WeakSet.prototype.delete; -const WEAK_SET_HAS = WeakSet.prototype.has; +const WEAK_SET_ADD = Function.prototype.call.bind(WeakSet.prototype.add) as ( + set: WeakSet, value: T, +) => WeakSet; +const WEAK_SET_DELETE = Function.prototype.call.bind(WeakSet.prototype.delete) as ( + set: WeakSet, value: T, +) => boolean; +const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as ( + set: WeakSet, value: T, +) => boolean; export type JsonValue = | null @@ -79,14 +89,14 @@ function snapshot( // Node and Bun expose this trap-free brand check, so reject before touching // its prototype, keys, descriptors, or identity collection. if (isProxy(value)) throw nonJson(at, 'Proxy objects are not allowed'); - if (WEAK_SET_HAS.call(ancestors, value)) throw nonJson(at, 'cycles are not allowed'); - WEAK_SET_ADD.call(ancestors, value); + if (WEAK_SET_HAS(ancestors, value)) throw nonJson(at, 'cycles are not allowed'); + WEAK_SET_ADD(ancestors, value); try { return ARRAY_IS_ARRAY(value) ? snapshotArray(value, at, ancestors, budget, depth) : snapshotObject(value, at, ancestors, budget, depth); } finally { - WEAK_SET_DELETE.call(ancestors, value); + WEAK_SET_DELETE(ancestors, value); } } @@ -122,7 +132,7 @@ function snapshotArray( if (index > 0) consumeBytes(budget, 1, at); const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, STRING(index)); if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); - ARRAY_PUSH.call(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); + ARRAY_PUSH(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); } // JSON.stringify consults `toJSON` before applying array semantics. Shadow // any poisoned Array.prototype hook with inert, non-JSON-visible data while @@ -191,7 +201,7 @@ function isArrayIndex(key: string, length: number): boolean { function propertyPath(at: string, key: string): string { if (key.length > 100) return `${at}[long property]`; - return REGEXP_TEST.call(/^[A-Za-z_$][A-Za-z0-9_$]*$/, key) + return REGEXP_TEST(/^[A-Za-z_$][A-Za-z0-9_$]*$/, key) ? `${at}.${key}` : `${at}[${JSON_STRINGIFY(key)}]`; } @@ -219,13 +229,13 @@ function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): throw nonJson(at, 'snapshot byte limit exceeded'); } for (let index = 0; index < value.length; index += 1) { - const code = STRING_CHAR_CODE_AT.call(value, index); + const code = STRING_CHAR_CODE_AT(value, index); if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { consumeBytes(budget, 2, at); } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) { if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const low = STRING_CHAR_CODE_AT.call(value, index + 1); + const low = STRING_CHAR_CODE_AT(value, index + 1); if (low >= 0xdc00 && low <= 0xdfff) { consumeBytes(budget, 4, at); index += 1; diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 7f1220718..2182721d4 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -229,11 +229,21 @@ describe('native Babysitter extension', () => { it('validates opaque runtime authority with captured WeakSet and WeakMap methods', async () => { const originalHas = WeakSet.prototype.has; const originalGet = WeakMap.prototype.get; + const originalCall = Function.prototype.call; + const reflectApply = Reflect.apply; let poisonCalls = 0; let failure: unknown; try { WeakSet.prototype.has = (() => { poisonCalls += 1; return true; }) as typeof WeakSet.prototype.has; WeakMap.prototype.get = (() => { poisonCalls += 1; return undefined; }) as typeof WeakMap.prototype.get; + Function.prototype.call = function poisonedCall( + this: Function, + thisArg: unknown, + ...args: unknown[] + ) { + if (this === originalHas || this === originalGet) poisonCalls += 1; + return reflectApply(originalCall, this, [thisArg, ...args]); + } as typeof Function.prototype.call; await assertHostedRuntimeAuthority( installed.hostedRuntime.installation, installed.hostedRuntime.base, @@ -244,6 +254,7 @@ describe('native Babysitter extension', () => { failure = error; } } finally { + Function.prototype.call = originalCall; WeakSet.prototype.has = originalHas; WeakMap.prototype.get = originalGet; } From 994c856209fa05de22f5537bb5dc003fd11403ac Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:39:26 -0700 Subject: [PATCH 29/80] test(sdk): synchronize premature hosted completion Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 +++++++++++++++++++ .../tests/hosted-extension-protocol.test.ts | 36 ++++++++++++++----- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 982b09ae0..7d392ff39 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -306,3 +306,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 184 passed (184) Start at 11:28:02 Duration 1.99s (transform 1.22s, setup 0ms, collect 5.83s, tests 3.86s, environment 1ms, prepare 484ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 104ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1059ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1022ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 424ms + ✓ tests/authored-flow.test.ts (34 tests) 759ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 600ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 830ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:38:51 + Duration 2.06s (transform 1.25s, setup 0ms, collect 6.00s, tests 3.79s, environment 1ms, prepare 442ms) diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 522d25c0d..f526cf373 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -1,14 +1,20 @@ import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; -import { boundedJsonSnapshot } from '../src/hosted-extension-protocol.js'; +import { + boundedJsonSnapshot, + exchangeHostedExtension, +} from '../src/hosted-extension-protocol.js'; import { snapshotJsonValue } from '../src/json-value.js'; import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; import { materializePlugin } from '../src/plugin-store.js'; @@ -308,14 +314,26 @@ describe('hosted extension hostile protocol', () => { let markInvoked!: () => void; const invoked = new Promise(resolve => { markInvoked = resolve; }); let calls = 0; - const run = runVerifiedNativeExtensionSandbox({ - artifact: await artifact(hostileImport([ - capabilityFrame(), - { type: 'result', completionReason: 'success', capabilityCalls: 1 }, - ])), - manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, - babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, - }); + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const run = exchangeHostedExtension( + child, + protocol, + stdin, + stderr, + 10_000, + { type: 'run' }, + async () => { calls += 1; markInvoked(); return await adapter; }, + ); + protocol.write(`${JSON.stringify(capabilityFrame())}\n${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); await waitForInvocation(invoked); settle({ receiptId: 'settled-after-refusal', status: 'queued' }); await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); From e0348e0d027aa37c78950f9fc607b36bb75859e0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:54:45 -0700 Subject: [PATCH 30/80] fix(sdk): capture hosted sandbox intrinsics Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 ++++++ packages/sdk/src/bundle.ts | 40 ++++++- packages/sdk/src/hosted-extension-protocol.ts | 3 +- packages/sdk/src/hosted-extension-sandbox.ts | 107 +++++++++++------- packages/sdk/src/plugin-store.ts | 74 ++++++++---- .../tests/hosted-extension-protocol.test.ts | 40 +++++++ .../tests/hosted-extension-routing.test.ts | 61 +++++++++- .../sdk/tests/plugin-store-bounds.test.ts | 21 ++++ 8 files changed, 313 insertions(+), 69 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 7d392ff39..946a531c5 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -342,3 +342,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 184 passed (184) Start at 11:38:51 Duration 2.06s (transform 1.25s, setup 0ms, collect 6.00s, tests 3.79s, environment 1ms, prepare 442ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (4 tests) 8ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 666ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 626ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 509ms + ✓ tests/authored-flow.test.ts (34 tests) 778ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 631ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 797ms + + Test Files 8 passed (8) + Tests 187 passed (187) + Start at 11:53:48 + Duration 2.01s (transform 1.14s, setup 0ms, collect 5.85s, tests 3.50s, environment 1ms, prepare 506ms) diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index 1e8a529f6..20c0448cd 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -12,6 +12,12 @@ const HASH_DIGEST = Function.prototype.call.bind(createHash('sha256').digest) as hash: Hash, encoding: 'hex', ) => string; +const STRING_INCLUDES = Function.prototype.call.bind(String.prototype.includes) as ( + value: string, search: string, +) => boolean; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, separator: string, +) => string[]; export interface BundleEntry { path: string; sha256: string; bytes: number } export interface BundleFile { path: string; data: Uint8Array | string; executable?: boolean } @@ -32,9 +38,14 @@ export function sha256(data: Uint8Array | string): string { /** A bundle-relative path: no empty, `.`, or `..` component, no backslash, NUL, or drive colon. */ export function safePath(path: string): boolean { - return path.length > 0 && !path.includes('\\') && !path.includes('\0') - && path.split('/').every(part => part !== '' && part !== '.' && part !== '..') - && !path.includes(':'); + if (path.length === 0 || STRING_INCLUDES(path, '\\') || STRING_INCLUDES(path, '\0') + || STRING_INCLUDES(path, ':')) return false; + const parts = STRING_SPLIT(path, '/'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + if (part === '' || part === '.' || part === '..') return false; + } + return true; } /** @@ -43,9 +54,26 @@ export function safePath(path: string): boolean { * thing everywhere: the digest of `[{bytes,path,sha256}]`, sorted by path. */ export function payloadManifest(files: readonly { path: string; data: Uint8Array }[]): string { - return canonicalize([...files] - .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - .map(file => ({ path: file.path, sha256: sha256(file.data), bytes: file.data.length }))); + const sorted: { path: string; data: Uint8Array }[] = []; + for (let index = 0; index < files.length; index += 1) sorted[index] = files[index]!; + // Do not consult mutable Array prototype methods here. Authored base code + // executes in this process before hosted artifact staging, so a live + // sort/map lookup would let it substitute bytes or manifest records. + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index]!; + let position = index; + while (position > 0 && sorted[position - 1]!.path > current.path) { + sorted[position] = sorted[position - 1]!; + position -= 1; + } + sorted[position] = current; + } + const entries: { path: string; sha256: string; bytes: number }[] = []; + for (let index = 0; index < sorted.length; index += 1) { + const file = sorted[index]!; + entries[index] = { path: file.path, sha256: sha256(file.data), bytes: file.data.length }; + } + return canonicalize(entries); } /** Manifest and identity are envelopes, excluded to avoid circular hashing. */ diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 140d9662f..029d54821 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -12,6 +12,7 @@ const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; +const OBJECT_FREEZE = Object.freeze; const STRING = String; const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) as ( value: string, search: string, @@ -162,7 +163,7 @@ export async function exchangeHostedExtension( || message.completionReason !== 'success' || message.capabilityCalls !== 1) { return refuse('Hosted extension reported a completion without exactly one capability call.'); } - finish(undefined, Object.freeze({ completionReason: 'success', capabilityCalls: 1 })); + finish(undefined, OBJECT_FREEZE({ completionReason: 'success', capabilityCalls: 1 })); } else if (message.type === 'error') { if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { return refuse('Hosted extension emitted a malformed error frame.'); diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 99d9585c4..5bc1e703b 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -15,8 +15,15 @@ import { import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; +const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], ...values: T[]) => number; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; +const OBJECT_ENTRIES = Object.entries; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_KEYS = Object.keys; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; const DEFAULT_TIMEOUT_MS = 10_000; // These hard limits are inherited across prlimit -> bubblewrap -> Node and its // descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 @@ -26,7 +33,7 @@ const DEFAULT_TIMEOUT_MS = 10_000; // the pinned TypeScript handler from starting on supported Node releases. const ADDRESS_SPACE_BYTES = 16 * 1024 * 1024 * 1024; const DATA_BYTES = 3 * 1024 * 1024 * 1024; -const SURFACE_RUNTIME_SHA256 = Object.freeze({ +const SURFACE_RUNTIME_SHA256 = OBJECT_FREEZE({ 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', 'provider-trigger.js': 'e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff', @@ -68,7 +75,7 @@ export async function runHostedExtensionSandbox( return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); } const entryPath = resolve(options.artifactDirectory, options.entry); - if (!entryPath.startsWith(`${resolve(options.artifactDirectory)}/`)) { + if (!STRING_STARTS_WITH(entryPath, `${resolve(options.artifactDirectory)}/`)) { throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); } const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion, options.surfaceRoot); @@ -77,11 +84,12 @@ export async function runHostedExtensionSandbox( const surfaceFacade = join(runtimeDirectory, 'surface'); try { const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); - const snapshot = await materializePlugin( - runtimeDirectory, - 'hosted-extension', - storedFiles.filter(file => file.path !== 'manifest.json'), - ); + const payloadFiles: { path: string; data: Buffer }[] = []; + for (let index = 0; index < storedFiles.length; index += 1) { + const file = storedFiles[index]!; + if (file.path !== 'manifest.json') payloadFiles[payloadFiles.length] = file; + } + const snapshot = await materializePlugin(runtimeDirectory, 'hosted-extension', payloadFiles); if (snapshot.digest !== options.artifactDigest) { throw new PluginError( 'plugin_source_drift', @@ -97,11 +105,9 @@ export async function runHostedExtensionSandbox( surfaceFacade, }); await options.beforeLaunch?.(); - const child = spawn(prlimit, [ - `--as=${ADDRESS_SPACE_BYTES}`, - `--data=${DATA_BYTES}`, - '--', bwrap, ...args, - ], { + const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; + for (let index = 0; index < args.length; index += 1) commandArgs[commandArgs.length] = args[index]!; + const child = spawn(prlimit, commandArgs, { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'], }); return await exchangeHostedExtension( @@ -161,48 +167,54 @@ function checkedSurfaceRoot(root: string, expectedVersion: string): string { } async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promise { - await Promise.all([ - mkdir(join(directory, 'dist/helpers'), { recursive: true }), - mkdir(join(directory, 'dist/triggers'), { recursive: true }), - ]); - const runtimeFiles = Object.entries(SURFACE_RUNTIME_SHA256).map(([file, expected]) => { + await mkdir(join(directory, 'dist/helpers'), { recursive: true }); + await mkdir(join(directory, 'dist/triggers'), { recursive: true }); + const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); + const runtimeFiles: { file: string; bytes: Buffer; expected: string }[] = []; + for (let index = 0; index < entries.length; index += 1) { + const file = entries[index]![0]; + const expected = entries[index]![1]; let bytes: Buffer; try { bytes = readFileSync(join(surfaceRoot, 'dist', file)); } catch { return unsupported(`hosted extension cannot read pinned Surface runtime ${file}`); } if (sha256(bytes) !== expected) { return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); } - return { file, bytes, expected }; - }); - await Promise.all([ - writeFile(join(directory, 'package.json'), JSON_STRINGIFY({ - name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, - }), { mode: 0o400, flag: 'wx' }), - writeFile(join(directory, 'index.js'), - "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", - { mode: 0o400, flag: 'wx' }), - writeFile(join(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", - { mode: 0o400, flag: 'wx' }), - ...runtimeFiles.map(({ file, bytes }) => writeFile(join(directory, 'dist', file), bytes, - { mode: 0o400, flag: 'wx' })), - ]); - for (const { file, expected } of runtimeFiles) { + runtimeFiles[index] = { file, bytes, expected }; + } + await writeFile(join(directory, 'package.json'), JSON_STRINGIFY({ + name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, + }), { mode: 0o400, flag: 'wx' }); + await writeFile(join(directory, 'index.js'), + "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", + { mode: 0o400, flag: 'wx' }); + await writeFile(join(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", + { mode: 0o400, flag: 'wx' }); + for (let index = 0; index < runtimeFiles.length; index += 1) { + const { file, bytes } = runtimeFiles[index]!; + await writeFile(join(directory, 'dist', file), bytes, { mode: 0o400, flag: 'wx' }); + } + for (let index = 0; index < runtimeFiles.length; index += 1) { + const { file, expected } = runtimeFiles[index]!; if (sha256(readFileSync(join(directory, 'dist', file))) !== expected) { throw new PluginError('plugin_source_drift', `Private Surface runtime snapshot changed at ${file}.`); } } } -function sandboxArguments(input: { +/** @internal Pure construction seam for hostile-intrinsic regressions. */ +export function sandboxArguments(input: { node: string; runner: string; extension: string; surfaceFacade: string; }): string[] { const args = [ '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', '--dir', '/usr', ]; - for (const path of ['/usr/lib', '/usr/lib64', '/lib', '/lib64']) { - if (existsSync(path)) args.push('--ro-bind', realpathSync(path), path); + const libraryPaths = ['/usr/lib', '/usr/lib64', '/lib', '/lib64']; + for (let index = 0; index < libraryPaths.length; index += 1) { + const path = libraryPaths[index]!; + if (existsSync(path)) ARRAY_PUSH(args, '--ro-bind', realpathSync(path), path); } - args.push( + ARRAY_PUSH(args, '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', @@ -213,7 +225,12 @@ function sandboxArguments(input: { '--dir', '/extension/node_modules/@relayflows/surface/dist', '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', - ...surfaceRuntimeMounts(input.surfaceFacade), + ); + const runtimeMounts = surfaceRuntimeMounts(input.surfaceFacade); + for (let index = 0; index < runtimeMounts.length; index += 1) { + args[args.length] = runtimeMounts[index]!; + } + ARRAY_PUSH(args, '--ro-bind', input.extension, '/extension/src', '--chdir', '/extension/src', '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', @@ -223,10 +240,18 @@ function sandboxArguments(input: { } function surfaceRuntimeMounts(surfaceFacade: string): string[] { - return Object.keys(SURFACE_RUNTIME_SHA256).flatMap(file => [ - '--ro-bind', realpathSync(join(surfaceFacade, 'dist', file)), - `/extension/node_modules/@relayflows/surface/dist/${file}`, - ]); + const files = OBJECT_KEYS(SURFACE_RUNTIME_SHA256); + const mounts: string[] = []; + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + ARRAY_PUSH( + mounts, + '--ro-bind', + realpathSync(join(surfaceFacade, 'dist', file)), + `/extension/node_modules/@relayflows/surface/dist/${file}`, + ); + } + return mounts; } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index a86d59276..bd0b5633a 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -17,7 +17,28 @@ import { PluginError } from './plugin-manifest.js'; export const PLUGIN_STORE = '.flows/plugins'; const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; +const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, encoding: BufferEncoding, +) => string; const JSON_PARSE = JSON.parse; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const OBJECT_FREEZE = Object.freeze; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + pattern: RegExp, value: string, +) => boolean; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; +const SET_FOR_EACH = Function.prototype.call.bind(Set.prototype.forEach) as ( + set: Set, callback: (value: T) => void, +) => void; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, separator: string, +) => string[]; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); @@ -47,7 +68,8 @@ export async function materializePlugin(root: string, name: string, files: reado await mkdir(parent, { recursive: true }); const staging = await mkdtemp(join(parent, '.install-')); try { - for (const file of files) { + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; if (!safePath(file.path) || file.path === 'manifest.json') throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`); await mkdir(dirname(join(staging, file.path)), { recursive: true }); await writeFile(join(staging, file.path), file.data, { mode: 0o644 }); @@ -55,7 +77,8 @@ export async function materializePlugin(root: string, name: string, files: reado await writeFile(join(staging, 'manifest.json'), manifest); try { await rename(staging, directory); } catch (error) { - if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EEXIST' && code !== 'ENOTEMPTY') throw error; await verifyStoredPlugin(directory, digest); } } finally { await rm(staging, { recursive: true, force: true }); } @@ -108,10 +131,12 @@ async function openStoredFile( path: string, hooks: StoredPluginReadTestHooks, ): Promise>> { - const parts = path.split('/'); + const parts = STRING_SPLIT(path, '/'); if (process.platform !== 'linux') { for (let i = 1; i < parts.length; i++) { - if (!(await lstat(join(root, ...parts.slice(0, i)))).isDirectory()) { + let parent = root; + for (let index = 0; index < i; index += 1) parent = join(parent, parts[index]!); + if (!(await lstat(parent)).isDirectory()) { throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } } @@ -123,7 +148,8 @@ async function openStoredFile( if (!(await directory.stat()).isDirectory()) { throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`); } - for (const part of parts.slice(0, -1)) { + for (let index = 0; index < parts.length - 1; index += 1) { + const part = parts[index]!; const child = await open(`/proc/self/fd/${directory.fd}/${part}`, READ_FLAGS); if (!(await child.stat()).isDirectory()) { await child.close(); @@ -133,7 +159,7 @@ async function openStoredFile( directory = child; } await hooks.beforeOpen?.(join(root, path)); - return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)!}`, READ_FLAGS); + return await open(`/proc/self/fd/${directory.fd}/${parts[parts.length - 1]!}`, READ_FLAGS); } finally { await directory.close(); } @@ -159,31 +185,37 @@ async function readVerifiedStoredPluginFiles( if (!(await lstat(directory)).isDirectory()) return drift('not a directory'); manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); } - const raw = manifest.toString('utf8'); + const raw = BUFFER_TO_STRING(manifest, 'utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); let entries: { path: string; sha256: string; bytes: number }[]; - try { entries = JSON_PARSE(raw); if (!Array.isArray(entries)) throw new Error(); } + try { entries = JSON_PARSE(raw); if (!ARRAY_IS_ARRAY(entries)) throw new Error(); } catch { return drift('manifest.json is not a manifest'); } if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`); - const paths = new Set(); + const paths = new SET(); const files = [{ path: 'manifest.json', data: manifest }]; let totalBytes = 0; - for (const entry of entries) { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; if (typeof entry?.path !== 'string' || !safePath(entry.path) - || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256) - || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_PLUGIN_FILE_BYTES - || paths.has(entry.path)) return drift('manifest.json lists an invalid file'); + || typeof entry.sha256 !== 'string' || !REGEXP_TEST(/^[a-f0-9]{64}$/, entry.sha256) + || !NUMBER_IS_SAFE_INTEGER(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_PLUGIN_FILE_BYTES + || SET_HAS(paths, entry.path)) return drift('manifest.json lists an invalid file'); totalBytes += entry.bytes; if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`); let data: Buffer; try { data = await regularFile(directory, entry.path, MAX_PLUGIN_FILE_BYTES, entry.bytes, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); } if (sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); - paths.add(entry.path); - files.push({ path: entry.path, data }); + SET_ADD(paths, entry.path); + files[files.length] = { path: entry.path, data }; } - await rejectExtras(directory, '', new Set([...paths, 'manifest.json']), drift); - return Object.freeze(files.map(file => Object.freeze(file))); + SET_ADD(paths, 'manifest.json'); + await rejectExtras(directory, '', paths, drift); + const frozen: Readonly<{ path: string; data: Buffer }>[] = []; + for (let index = 0; index < files.length; index += 1) { + frozen[index] = OBJECT_FREEZE(files[index]!); + } + return OBJECT_FREEZE(frozen); } /** Re-verify, then return every stored file including the payload `manifest.json`. */ @@ -208,8 +240,12 @@ async function rejectExtras(root: string, prefix: string, paths: Set, dr entries += 1; if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries'); const path = currentPrefix + entry.name; - if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await visit(`${path}/`); - else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`); + let declaredDescendant = false; + SET_FOR_EACH(paths, file => { + if (STRING_STARTS_WITH(file, `${path}/`)) declaredDescendant = true; + }); + if (entry.isDirectory() && declaredDescendant) await visit(`${path}/`); + else if (!entry.isFile() || !SET_HAS(paths, path)) drift(`${path}: unlisted file or unsupported file type`); } } await visit(prefix); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index f526cf373..6ec6a1bce 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -340,6 +340,46 @@ describe('hosted extension hostile protocol', () => { expect(calls).toBe(1); }, 15_000); + it('freezes successful completion with the captured intrinsic', async () => { + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const freeze = Object.freeze; + let poisonCalls = 0; + let result: Awaited> | undefined; + try { + Object.freeze = ((value: object) => { + if ('completionReason' in value) { + poisonCalls += 1; + return { completionReason: 'forged', capabilityCalls: 0 } as typeof value; + } + return freeze(value); + }) as typeof Object.freeze; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write(`${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); + } + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => ({ receiptId: 'receipt-captured-freeze', status: 'queued' }), + ); + protocol.write(`${JSON.stringify(capabilityFrame())}\n`); + result = await run; + } finally { + Object.freeze = freeze; + } + expect(poisonCalls).toBe(0); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + }); + it('rejects non-object protocol output with zero adapter calls', async () => { let calls = 0; await expect(runVerifiedNativeExtensionSandbox({ diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index de11c9da1..79ba4315b 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; import { github } from '@relayflows/surface'; import { extensionHandlerForHostedDispatch, @@ -6,7 +9,10 @@ import { } from '../src/flow-extension-loader.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; -import { supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; +import { sandboxArguments, supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); describe('hosted extension routing policy', () => { it('accepts only Node releases that implement every sandbox flag', () => { @@ -18,6 +24,57 @@ describe('hosted extension routing policy', () => { expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); }); + it('constructs the pinned Surface mounts without ambient array methods', () => { + const root = mkdtempSync(join(tmpdir(), 'hosted-sandbox-arguments-')); + roots.push(root); + const facade = join(root, 'surface'); + for (const file of [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', + 'triggers.js', 'triggers/github.js', + ]) { + mkdirSync(join(facade, 'dist', file, '..'), { recursive: true }); + writeFileSync(join(facade, 'dist', file), file); + } + const flatMap = Array.prototype.flatMap; + const push = Array.prototype.push; + let calls = 0; + let args: string[] | undefined; + try { + Array.prototype.flatMap = function poisonedFlatMap( + this: unknown[], + callback: (value: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + if (this.length === 6 && this[0] === 'flow.js') { + calls += 1; + return ['--bind', '/attacker', '/runtime/runner.mjs'] as never[]; + } + return Reflect.apply(flatMap, this, [callback, thisArg]) as never[]; + } as typeof Array.prototype.flatMap; + Array.prototype.push = function poisonedPush(this: unknown[], ...values: unknown[]) { + if (this[0] === '--unshare-all') { + calls += 1; + return this.length; + } + return Reflect.apply(push, this, values); + } as typeof Array.prototype.push; + args = sandboxArguments({ + node: '/trusted/node', runner: '/trusted/runner', extension: '/trusted/extension', surfaceFacade: facade, + }); + } finally { + Array.prototype.flatMap = flatMap; + Array.prototype.push = push; + } + expect(calls).toBe(0); + expect(args).not.toContain('/attacker'); + for (const file of [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', + 'triggers.js', 'triggers/github.js', + ]) { + expect(args).toContain(`/extension/node_modules/@relayflows/surface/dist/${file}`); + } + }); + it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { const body = async () => {}; const specific = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 4659d3169..51d3d8341 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { payloadManifest } from '../src/bundle.js'; import { materializePlugin, readStoredPluginFiles, verifyStoredPlugin } from '../src/plugin-store.js'; const roots: string[] = []; @@ -18,6 +19,26 @@ async function fixture() { } describe('bounded plugin-store verification', () => { + it('hashes the exact supplied buffers without ambient sort or map', () => { + const sort = Array.prototype.sort; + const map = Array.prototype.map; + let calls = 0; + let encoded: string | undefined; + try { + Array.prototype.sort = (() => { calls += 1; return []; }) as typeof Array.prototype.sort; + Array.prototype.map = (() => { calls += 1; return []; }) as typeof Array.prototype.map; + encoded = payloadManifest([ + { path: 'z.ts', data: Buffer.from('z') }, + { path: 'a.ts', data: Buffer.from('a') }, + ]); + } finally { + Array.prototype.sort = sort; + Array.prototype.map = map; + } + expect(calls).toBe(0); + expect(JSON.parse(encoded!)).toMatchObject([{ path: 'a.ts' }, { path: 'z.ts' }]); + }); + it('refuses an oversized sparse payload before buffering it', async () => { const stored = await fixture(); truncateSync(join(stored.directory, 'entry.ts'), 256_001); From 40eb09444bad9c7d14bb9b4a0f606a36f88d9a7a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 11:57:08 -0700 Subject: [PATCH 31/80] fix(sdk): capture hosted adapter continuation Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 +++++++++++++++++++ packages/sdk/src/hosted-extension-protocol.ts | 7 +++- .../tests/hosted-extension-protocol.test.ts | 15 ++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 946a531c5..732814147 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -378,3 +378,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 187 passed (187) Start at 11:53:48 Duration 2.01s (transform 1.14s, setup 0ms, collect 5.85s, tests 3.50s, environment 1ms, prepare 506ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 615ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 582ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 403ms + ✓ tests/authored-flow.test.ts (34 tests) 764ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 663ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 794ms + + Test Files 8 passed (8) + Tests 187 passed (187) + Start at 11:56:31 + Duration 2.02s (transform 1.15s, setup 0ms, collect 5.93s, tests 3.36s, environment 1ms, prepare 520ms) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 029d54821..69d9cb885 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -13,6 +13,11 @@ const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; const OBJECT_FREEZE = Object.freeze; +const PROMISE_THEN = Function.prototype.call.bind(Promise.prototype.then) as ( + promise: Promise, + fulfilled: (value: unknown) => void, + rejected: (reason: unknown) => void, +) => Promise; const STRING = String; const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) as ( value: string, search: string, @@ -134,7 +139,7 @@ export async function exchangeHostedExtension( return refuse('Hosted extension requested an undeclared or repeated capability.'); } capabilityState = 'pending'; - void invoke(message.request).then( + void PROMISE_THEN(invoke(message.request), value => { if (settled) return; try { diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index 6ec6a1bce..d4346067f 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -340,7 +340,7 @@ describe('hosted extension hostile protocol', () => { expect(calls).toBe(1); }, 15_000); - it('freezes successful completion with the captured intrinsic', async () => { + it('settles successful completion with captured intrinsics', async () => { const protocol = new PassThrough(); const stdin = new PassThrough(); const stderr = new PassThrough(); @@ -350,6 +350,8 @@ describe('hosted extension hostile protocol', () => { kill: () => true, }) as unknown as ChildProcess; const freeze = Object.freeze; + const then = Promise.prototype.then; + const adapter = Promise.resolve({ receiptId: 'receipt-captured-intrinsics', status: 'queued' }); let poisonCalls = 0; let result: Awaited> | undefined; try { @@ -360,6 +362,14 @@ describe('hosted extension hostile protocol', () => { } return freeze(value); }) as typeof Object.freeze; + Promise.prototype.then = function poisonedThen(this: Promise, onfulfilled, onrejected) { + if (this === adapter) { + poisonCalls += 1; + onfulfilled?.({ receiptId: 'forged', status: 'queued' }); + return Promise.resolve(undefined) as Promise; + } + return Reflect.apply(then, this, [onfulfilled, onrejected]) as Promise; + } as typeof Promise.prototype.then; stdin.on('data', chunk => { if (String(chunk).includes('capability-result')) { protocol.write(`${JSON.stringify({ @@ -369,12 +379,13 @@ describe('hosted extension hostile protocol', () => { }); const run = exchangeHostedExtension( child, protocol, stdin, stderr, 10_000, { type: 'run' }, - async () => ({ receiptId: 'receipt-captured-freeze', status: 'queued' }), + () => adapter, ); protocol.write(`${JSON.stringify(capabilityFrame())}\n`); result = await run; } finally { Object.freeze = freeze; + Promise.prototype.then = then; } expect(poisonCalls).toBe(0); expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); From 6c865a68f322a1c3c5edf011d86875f247a4d660 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 12:15:12 -0700 Subject: [PATCH 32/80] fix(sdk): capture hosted protocol promise Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 36 ++++++++++++ packages/sdk/src/hosted-extension-protocol.ts | 3 +- ...sted-extension-protocol-intrinsics.test.ts | 57 +++++++++++++++++++ packages/sdk/tsconfig.tests.json | 1 + 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 732814147..7b57c77d2 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -414,3 +414,39 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 187 passed (187) Start at 11:56:31 Duration 2.02s (transform 1.15s, setup 0ms, collect 5.93s, tests 3.36s, environment 1ms, prepare 520ms) + +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (1 test) 3ms + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 379ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 358ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 458ms + ✓ tests/authored-flow.test.ts (34 tests) 761ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 646ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 800ms + + Test Files 9 passed (9) + Tests 188 passed (188) + Start at 12:14:23 + Duration 2.07s (transform 1.21s, setup 0ms, collect 6.34s, tests 3.17s, environment 2ms, prepare 555ms) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 69d9cb885..0da4178b0 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -13,6 +13,7 @@ const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; const OBJECT_FREEZE = Object.freeze; +const PROMISE = Promise; const PROMISE_THEN = Function.prototype.call.bind(Promise.prototype.then) as ( promise: Promise, fulfilled: (value: unknown) => void, @@ -74,7 +75,7 @@ export async function exchangeHostedExtension( }); protocol.setEncoding('utf8'); - return await new Promise((resolvePromise, rejectPromise) => { + return await new PROMISE((resolvePromise, rejectPromise) => { let settled = false; let capabilityState: 'none' | 'pending' | 'completed' | 'failed' = 'none'; let capabilityError: Error | undefined; diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts new file mode 100644 index 000000000..79f550514 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -0,0 +1,57 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { expect, it } from 'vitest'; +import { exchangeHostedExtension } from '../src/hosted-extension-protocol.js'; + +it('constructs protocol completion with the captured Promise', async () => { + const NativePromise = Promise; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let adapterCalls = 0; + let poisonCalls = 0; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write(`${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); + } + }); + + class PoisonedPromise extends NativePromise { + constructor(executor: ConstructorParameters>[0]) { + poisonCalls += 1; + super(executor); + return NativePromise.resolve({ + completionReason: 'success', capabilityCalls: 1, + }) as unknown as PoisonedPromise; + } + } + + let run!: ReturnType; + try { + globalThis.Promise = PoisonedPromise as PromiseConstructor; + run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { + adapterCalls += 1; + return { receiptId: 'receipt-captured-constructor', status: 'queued' }; + }, + ); + } finally { + globalThis.Promise = NativePromise; + } + + protocol.write(`${JSON.stringify({ + type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: 'exact' }, + })}\n`); + await expect(run).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(adapterCalls).toBe(1); + expect(poisonCalls).toBe(0); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 8bbe1b491..a8aeec620 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -30,6 +30,7 @@ "tests/hosted-extension-routing.test.ts", "tests/hosted-base-snapshot.test.ts", "tests/plugin-store-bounds.test.ts", + "tests/hosted-extension-protocol-intrinsics.test.ts", "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", From 070e097e4dae549c6a47a300afc09dadc52186b0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 12:30:01 -0700 Subject: [PATCH 33/80] fix(sdk): capture hosted bound intrinsics Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 33 ++++++++++++++ packages/sdk/src/hosted-base-snapshot.ts | 3 +- packages/sdk/src/hosted-extension-protocol.ts | 14 ++++-- packages/sdk/src/hosted-extension-runtime.ts | 3 +- packages/sdk/src/plugin-store.ts | 5 ++- ...sted-extension-protocol-intrinsics.test.ts | 45 +++++++++++++++++++ .../sdk/tests/plugin-store-bounds.test.ts | 19 ++++++-- 7 files changed, 111 insertions(+), 11 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 7b57c77d2..6969e4c87 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -450,3 +450,36 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 188 passed (188) Start at 12:14:23 Duration 2.07s (transform 1.21s, setup 0ms, collect 6.34s, tests 3.17s, environment 2ms, prepare 555ms) + +$ cd packages/sdk +$ npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (2 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 616ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 582ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 464ms + ✓ tests/authored-flow.test.ts (34 tests) 718ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 618ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 810ms + + Test Files 9 passed (9) + Tests 189 passed (189) + Start at 12:29:00 + Duration 2.04s (transform 1.32s, setup 0ms, collect 6.12s, tests 3.35s, environment 1ms, prepare 543ms) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index c28a39a3a..50c9ea579 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -13,6 +13,7 @@ const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( array: T[], compare?: (left: T, right: T) => number, ) => T[]; const OBJECT_FREEZE = Object.freeze; +const BIG_INT = BigInt; const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; const STRING_LOCALE_COMPARE = Function.prototype.call.bind(String.prototype.localeCompare) as ( value: string, other: string, @@ -178,7 +179,7 @@ async function readTree( if (before.isDirectory()) { await visit(handle, relativePath, depth + 1); } else if (before.isFile()) { - if (before.size < 0n || before.size > BigInt(MAX_BYTES - budget.bytes)) throw tooLarge(); + if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); await hooks.afterStat?.(absolutePath); const bytes = await readBounded(handle, Number(before.size), relativePath); const after = await handle.stat({ bigint: true }); diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index 0da4178b0..f5025d988 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; +import { clearTimeout as nodeClearTimeout, setTimeout as nodeSetTimeout } from 'node:timers'; import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; @@ -19,6 +20,13 @@ const PROMISE_THEN = Function.prototype.call.bind(Promise.prototype.then) as ( fulfilled: (value: unknown) => void, rejected: (reason: unknown) => void, ) => Promise; +const CLEAR_TIMEOUT = nodeClearTimeout; +const SET_TIMEOUT = nodeSetTimeout; +const TIMER_SAMPLE = SET_TIMEOUT(() => undefined, 0); +const TIMER_UNREF = Function.prototype.call.bind(TIMER_SAMPLE.unref) as ( + timer: ReturnType, +) => ReturnType; +CLEAR_TIMEOUT(TIMER_SAMPLE); const STRING = String; const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) as ( value: string, search: string, @@ -83,7 +91,7 @@ export async function exchangeHostedExtension( const finish = (error?: Error, result?: HostedExtensionProtocolResult) => { if (settled) return; settled = true; - clearTimeout(timeout); + CLEAR_TIMEOUT(timeout); stdin.end(); if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); if (error !== undefined) rejectPromise(error); @@ -101,7 +109,7 @@ export async function exchangeHostedExtension( } finish(capabilityError ?? error); }; - const timeout = setTimeout(() => { + const timeout = SET_TIMEOUT(() => { child.kill('SIGKILL'); finish(new PluginError( 'plugin_unsupported', @@ -110,7 +118,7 @@ export async function exchangeHostedExtension( : 'Hosted extension sandbox timed out.', )); }, timeoutMs); - timeout.unref(); + TIMER_UNREF(timeout); stdin.on('error', () => refuse('Hosted extension capability channel closed.')); protocol.on('error', () => refuse('Hosted extension protocol channel failed.')); diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index f1fe747bf..808b984c0 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -24,6 +24,7 @@ const INSTALLATION_AUTHORITY = new WeakSet(); const BASE_AUTHORITY = new WeakSet(); const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; const MAX_DECLARATION_BYTES = 1024 * 1024; +const BIG_INT = BigInt; const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); @@ -274,7 +275,7 @@ function readBoundedDeclaration(path: string): Buffer { try { descriptor = openSync(path, DECLARATION_READ_FLAGS); const before = fstatSync(descriptor, { bigint: true }); - if (!before.isFile() || before.size < 0n || before.size > BigInt(MAX_DECLARATION_BYTES)) { + if (!before.isFile() || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); } const expected = Number(before.size); diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index bd0b5633a..4e53a4013 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -18,6 +18,7 @@ export const PLUGIN_STORE = '.flows/plugins'; const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; const ARRAY_IS_ARRAY = Array.isArray; +const BIG_INT = BigInt; const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( value: Buffer, encoding: BufferEncoding, ) => string; @@ -96,8 +97,8 @@ async function regularFile( const handle = await openStoredFile(root, path, hooks); try { const before = await handle.stat({ bigint: true }); - if (!before.isFile() || before.size < 0n || before.size > BigInt(maxBytes) - || (expectedBytes !== undefined && before.size !== BigInt(expectedBytes))) { + if (!before.isFile() || before.size < 0n || before.size > BIG_INT(maxBytes) + || (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes))) { throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); } await hooks.afterStat?.(absolute); diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts index 79f550514..571a09a65 100644 --- a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -55,3 +55,48 @@ it('constructs protocol completion with the captured Promise', async () => { expect(adapterCalls).toBe(1); expect(poisonCalls).toBe(0); }); + +it('enforces the protocol deadline with captured timer operations', async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const sample = nativeSetTimeout(() => undefined, 60_000); + nativeClearTimeout(sample); + const timerPrototype = Object.getPrototypeOf(sample) as { unref: () => unknown }; + const nativeUnref = timerPrototype.unref; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let setTimeoutCalls = 0; + let clearTimeoutCalls = 0; + let unrefCalls = 0; + let unrefCallsDuringSetup = 0; + try { + globalThis.setTimeout = (() => { + setTimeoutCalls += 1; + return { unref: () => { unrefCalls += 1; } }; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = (() => { clearTimeoutCalls += 1; }) as typeof clearTimeout; + timerPrototype.unref = () => { unrefCalls += 1; }; + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 5, { type: 'run' }, + async () => ({ receiptId: 'never', status: 'queued' }), + ); + unrefCallsDuringSetup = unrefCalls; + timerPrototype.unref = nativeUnref; + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } finally { + globalThis.setTimeout = nativeSetTimeout; + globalThis.clearTimeout = nativeClearTimeout; + timerPrototype.unref = nativeUnref; + } + expect({ setTimeoutCalls, clearTimeoutCalls, unrefCallsDuringSetup }).toEqual({ + setTimeoutCalls: 0, + clearTimeoutCalls: 0, + unrefCallsDuringSetup: 0, + }); +}); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 51d3d8341..d0a249044 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -51,10 +51,21 @@ describe('bounded plugin-store verification', () => { it('refuses an oversized sparse manifest before hashing it', async () => { const stored = await fixture(); truncateSync(join(stored.directory, 'manifest.json'), 4_000_001); - await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ - code: 'plugin_source_drift', - message: expect.stringContaining('expected a bounded regular file'), - }); + const bigint = BigInt; + let poisonCalls = 0; + try { + globalThis.BigInt = (() => { + poisonCalls += 1; + return 1n << 62n; + }) as unknown as BigIntConstructor; + await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('expected a bounded regular file'), + }); + } finally { + globalThis.BigInt = bigint; + } + expect(poisonCalls).toBe(0); }); it('bounds a payload that grows after its admitted size was checked', async () => { From 7bcea42ed31162dd1b36ee75ecaa276b1ddc2fcf Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 12:49:15 -0700 Subject: [PATCH 34/80] fix(sdk): bind hostile protocol operations Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 41 ++++++++++++ packages/sdk/src/hosted-base-snapshot.ts | 8 ++- packages/sdk/src/hosted-extension-protocol.ts | 57 +++++++++++----- packages/sdk/src/hosted-extension-runtime.ts | 15 +++-- packages/sdk/src/plugin-store.ts | 8 ++- .../sdk/tests/hosted-base-snapshot.test.ts | 25 +++++++ ...sted-extension-protocol-intrinsics.test.ts | 67 ++++++++++++++++++- .../sdk/tests/plugin-store-bounds.test.ts | 22 ++++++ 8 files changed, 213 insertions(+), 30 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 6969e4c87..72bbd8ba0 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -483,3 +483,44 @@ $ npm run typecheck && npm run build && npm run typecheck:tests && ./node_module Tests 189 passed (189) Start at 12:29:00 Duration 2.04s (transform 1.32s, setup 0ms, collect 6.12s, tests 3.35s, environment 1ms, prepare 543ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 110ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 439ms + ✓ tests/hosted-base-snapshot.test.ts (10 tests) 1073ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1029ms + ✓ tests/authored-flow.test.ts (34 tests) 751ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 648ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 841ms + + Test Files 9 passed (9) + Tests 192 passed (192) + Start at 12:48:17 + Duration 2.03s (transform 1.23s, setup 0ms, collect 6.01s, tests 3.90s, environment 1ms, prepare 507ms) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 50c9ea579..5a1d506ea 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -14,6 +14,8 @@ const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( ) => T[]; const OBJECT_FREEZE = Object.freeze; const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const NUMBER = Number; const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; const STRING_LOCALE_COMPARE = Function.prototype.call.bind(String.prototype.localeCompare) as ( value: string, other: string, @@ -181,7 +183,7 @@ async function readTree( } else if (before.isFile()) { if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); await hooks.afterStat?.(absolutePath); - const bytes = await readBounded(handle, Number(before.size), relativePath); + const bytes = await readBounded(handle, NUMBER(before.size), relativePath); const after = await handle.stat({ bigint: true }); if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { @@ -223,7 +225,7 @@ async function readBounded( expectedBytes: number, relativePath: string, ): Promise { - const bytes = Buffer.allocUnsafe(expectedBytes); + const bytes = BUFFER_ALLOC_UNSAFE(expectedBytes); let offset = 0; while (offset < expectedBytes) { const result = await handle.read(bytes, offset, expectedBytes - offset, offset); @@ -232,7 +234,7 @@ async function readBounded( } offset += result.bytesRead; } - const extra = Buffer.allocUnsafe(1); + const extra = BUFFER_ALLOC_UNSAFE(1); if ((await handle.read(extra, 0, 1, expectedBytes)).bytesRead !== 0) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index f5025d988..f7a6dcc30 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -1,5 +1,6 @@ -import type { ChildProcess } from 'node:child_process'; -import type { Readable, Writable } from 'node:stream'; +import { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { Readable, Writable } from 'node:stream'; import { clearTimeout as nodeClearTimeout, setTimeout as nodeSetTimeout } from 'node:timers'; import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; @@ -9,6 +10,12 @@ const MAX_FRAME_BYTES = 256 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const ARRAY_IS_ARRAY = Array.isArray; const BUFFER_BYTE_LENGTH = Buffer.byteLength; +const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( + child: ChildProcess, signal?: NodeJS.Signals | number, +) => boolean; +const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( + emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, +) => EventEmitter; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; const OBJECT_HAS_OWN = Object.hasOwn; @@ -20,6 +27,12 @@ const PROMISE_THEN = Function.prototype.call.bind(Promise.prototype.then) as ( fulfilled: (value: unknown) => void, rejected: (reason: unknown) => void, ) => Promise; +const READABLE_SET_ENCODING = Function.prototype.call.bind(Readable.prototype.setEncoding) as ( + stream: Readable, encoding: BufferEncoding, +) => Readable; +const READABLE_RESUME = Function.prototype.call.bind(Readable.prototype.resume) as ( + stream: Readable, +) => Readable; const CLEAR_TIMEOUT = nodeClearTimeout; const SET_TIMEOUT = nodeSetTimeout; const TIMER_SAMPLE = SET_TIMEOUT(() => undefined, 0); @@ -34,6 +47,12 @@ const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) a const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as ( value: string, start?: number, end?: number, ) => string; +const WRITABLE_END = Function.prototype.call.bind(Writable.prototype.end) as ( + stream: Writable, +) => Writable; +const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as ( + stream: Writable, chunk: string, +) => boolean; const SNAPSHOT_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 262_144, @@ -77,11 +96,12 @@ export async function exchangeHostedExtension( let buffer = ''; let stderrText = ''; let calls = 0; - stderr.setEncoding('utf8'); - stderr.on('data', chunk => { + READABLE_SET_ENCODING(stderr, 'utf8'); + EVENT_ON(stderr, 'data', chunk => { stderrText = STRING_SLICE(stderrText + STRING(chunk), -MAX_STDERR_BYTES); }); - protocol.setEncoding('utf8'); + READABLE_RESUME(stderr); + READABLE_SET_ENCODING(protocol, 'utf8'); return await new PROMISE((resolvePromise, rejectPromise) => { let settled = false; @@ -92,8 +112,8 @@ export async function exchangeHostedExtension( if (settled) return; settled = true; CLEAR_TIMEOUT(timeout); - stdin.end(); - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + WRITABLE_END(stdin); + if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); if (error !== undefined) rejectPromise(error); else resolvePromise(result!); }; @@ -102,7 +122,7 @@ export async function exchangeHostedExtension( : new PluginError('plugin_unsupported', 'Hosted capability rejected with a non-error value.'); const refuse = (message: string) => { const error = new PluginError('plugin_unsupported', message); - child.kill('SIGKILL'); + CHILD_PROCESS_KILL(child, 'SIGKILL'); if (capabilityState === 'pending') { deferredProtocolError ??= error; return; @@ -110,7 +130,7 @@ export async function exchangeHostedExtension( finish(capabilityError ?? error); }; const timeout = SET_TIMEOUT(() => { - child.kill('SIGKILL'); + CHILD_PROCESS_KILL(child, 'SIGKILL'); finish(new PluginError( 'plugin_unsupported', capabilityState === 'pending' @@ -120,9 +140,9 @@ export async function exchangeHostedExtension( }, timeoutMs); TIMER_UNREF(timeout); - stdin.on('error', () => refuse('Hosted extension capability channel closed.')); - protocol.on('error', () => refuse('Hosted extension protocol channel failed.')); - protocol.on('data', chunk => { + EVENT_ON(stdin, 'error', () => refuse('Hosted extension capability channel closed.')); + EVENT_ON(protocol, 'error', () => refuse('Hosted extension protocol channel failed.')); + EVENT_ON(protocol, 'data', chunk => { buffer += STRING(chunk); if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); for (;;) { @@ -155,11 +175,11 @@ export async function exchangeHostedExtension( const snapshot = boundedJsonSnapshot(value, 'hosted capability result'); capabilityState = 'completed'; if (deferredProtocolError !== undefined) return finish(deferredProtocolError); - stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); } catch (error) { capabilityError = failure(error); capabilityState = 'failed'; - stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); finish(capabilityError); } }, @@ -167,7 +187,7 @@ export async function exchangeHostedExtension( if (settled) return; capabilityError = failure(error); capabilityState = 'failed'; - stdin.write(`${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); finish(capabilityError); }, ); @@ -194,14 +214,15 @@ export async function exchangeHostedExtension( } else return refuse('Hosted extension emitted an unknown protocol message.'); } }); - child.once('error', () => refuse('Hosted extension sandbox could not start.')); - child.once('close', code => { + READABLE_RESUME(protocol); + EVENT_ON(child, 'error', () => refuse('Hosted extension sandbox could not start.')); + EVENT_ON(child, 'close', code => { if (!settled) refuse( `Hosted extension sandbox exited without a valid completion (exit ${code ?? 'signal'})` + (stderrText === '' ? '' : `: ${stderrText}`), ); }); - stdin.write(`${JSON_STRINGIFY(request)}\n`); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY(request)}\n`); }); } diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 808b984c0..8be681c77 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -25,6 +25,11 @@ const BASE_AUTHORITY = new WeakSet(); const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; const MAX_DECLARATION_BYTES = 1024 * 1024; const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, encoding: BufferEncoding, +) => string; +const NUMBER = Number; const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); @@ -218,7 +223,7 @@ function hostedDeclaredExtensions( throw new PluginError('plugin_lock_invalid', 'Hosted extension declarations have a pending transaction.'); } let config: unknown; - try { config = JSON_PARSE(readBoundedDeclaration(join(root, 'flows.json')).toString('utf8')); } + try { config = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(join(root, 'flows.json')), 'utf8')); } catch (error) { if (error instanceof PluginError) throw error; throw new PluginError('plugin_manifest_invalid', 'Invalid or oversized flows.json.'); @@ -247,7 +252,7 @@ function hostedDeclaredExtensions( const lockPath = join(root, PLUGIN_LOCK_FILE); if (existsSync(lockPath)) { let value: unknown; - try { value = JSON_PARSE(readBoundedDeclaration(lockPath).toString('utf8')); } + try { value = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(lockPath), 'utf8')); } catch (error) { if (error instanceof PluginError) throw error; throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`); @@ -278,15 +283,15 @@ function readBoundedDeclaration(path: string): Buffer { if (!before.isFile() || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); } - const expected = Number(before.size); - const bytes = Buffer.allocUnsafe(expected); + const expected = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(expected); let offset = 0; while (offset < expected) { const count = readSync(descriptor, bytes, offset, expected - offset, offset); if (count === 0) throw new Error('short read'); offset += count; } - if (readSync(descriptor, Buffer.allocUnsafe(1), 0, 1, expected) !== 0) throw new Error('grew'); + if (readSync(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) throw new Error('grew'); const after = fstatSync(descriptor, { bigint: true }); if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { throw new Error('changed'); diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 4e53a4013..b7e075210 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -19,10 +19,12 @@ const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; const ARRAY_IS_ARRAY = Array.isArray; const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( value: Buffer, encoding: BufferEncoding, ) => string; const JSON_PARSE = JSON.parse; +const NUMBER = Number; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; const OBJECT_FREEZE = Object.freeze; const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( @@ -102,8 +104,8 @@ async function regularFile( throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); } await hooks.afterStat?.(absolute); - const size = Number(before.size); - const bytes = Buffer.allocUnsafe(size); + const size = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(size); let offset = 0; while (offset < size) { const result = await handle.read(bytes, offset, size - offset, offset); @@ -112,7 +114,7 @@ async function regularFile( } offset += result.bytesRead; } - const extra = Buffer.allocUnsafe(1); + const extra = BUFFER_ALLOC_UNSAFE(1); if ((await handle.read(extra, 0, 1, size)).bytesRead !== 0) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index e60cd18e7..42f05fa41 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -100,6 +100,31 @@ describe('hosted base private snapshot', () => { }, ); + it('captures conversion and allocation across declarations and source snapshots', async () => { + const { flowPath } = fixture(); + const number = Number; + const allocUnsafe = Buffer.allocUnsafe; + let poisonCalls = 0; + try { + globalThis.Number = (() => { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + }) as unknown as NumberConstructor; + Buffer.allocUnsafe = (() => { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + }) as typeof Buffer.allocUnsafe; + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('reviewed Software Factory base source'), + }); + } finally { + globalThis.Number = number; + Buffer.allocUnsafe = allocUnsafe; + } + expect(poisonCalls).toBe(0); + }); + it('bounds a file that grows after its admitted size was checked', async () => { const { project } = fixture(); const raced = join(project, 'raced.bin'); diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts index 571a09a65..bc8034197 100644 --- a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { PassThrough } from 'node:stream'; +import { PassThrough, Readable, Writable } from 'node:stream'; import { expect, it } from 'vitest'; import { exchangeHostedExtension } from '../src/hosted-extension-protocol.js'; @@ -100,3 +100,68 @@ it('enforces the protocol deadline with captured timer operations', async () => unrefCallsDuringSetup: 0, }); }); + +it('registers and completes protocol I/O with captured stream operations', async () => { + const eventOn = EventEmitter.prototype.on; + const eventOnce = EventEmitter.prototype.once; + const setEncoding = Readable.prototype.setEncoding; + const resume = Readable.prototype.resume; + const write = Writable.prototype.write; + const end = Writable.prototype.end; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + let poisonCalls = 0; + let adapterCalls = 0; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => { poisonCalls += 1; return true; }, + }) as unknown as ChildProcess; + let rejection: unknown; + try { + EventEmitter.prototype.on = function poisonedOn(this: EventEmitter, ...args) { + poisonCalls += 1; + return Reflect.apply(eventOn, this, args); + } as typeof EventEmitter.prototype.on; + EventEmitter.prototype.once = function poisonedOnce(this: EventEmitter, ...args) { + poisonCalls += 1; + return Reflect.apply(eventOnce, this, args); + } as typeof EventEmitter.prototype.once; + Readable.prototype.setEncoding = function poisonedSetEncoding(this: Readable, ...args) { + poisonCalls += 1; + return Reflect.apply(setEncoding, this, args); + } as typeof Readable.prototype.setEncoding; + Readable.prototype.resume = function poisonedResume(this: Readable, ...args) { + poisonCalls += 1; + return Reflect.apply(resume, this, args); + } as typeof Readable.prototype.resume; + Writable.prototype.write = function poisonedWrite(this: Writable, ...args) { + poisonCalls += 1; + return Reflect.apply(write, this, args); + } as typeof Writable.prototype.write; + Writable.prototype.end = function poisonedEnd(this: Writable, ...args) { + poisonCalls += 1; + return Reflect.apply(end, this, args); + } as typeof Writable.prototype.end; + + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { + adapterCalls += 1; + return { receiptId: 'must-not-run', status: 'queued' }; + }, + ); + Reflect.apply(write, protocol, ['{}\n']); + try { await run; } catch (error) { rejection = error; } + } finally { + EventEmitter.prototype.on = eventOn; + EventEmitter.prototype.once = eventOnce; + Readable.prototype.setEncoding = setEncoding; + Readable.prototype.resume = resume; + Writable.prototype.write = write; + Writable.prototype.end = end; + } + expect(rejection).toMatchObject({ code: 'plugin_unsupported' }); + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 0 }); +}); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index d0a249044..b3b698ef1 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -68,6 +68,28 @@ describe('bounded plugin-store verification', () => { expect(poisonCalls).toBe(0); }); + it('uses captured conversion and allocation intrinsics after size admission', async () => { + const stored = await fixture(); + const number = Number; + const allocUnsafe = Buffer.allocUnsafe; + let poisonCalls = 0; + try { + globalThis.Number = (() => { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + }) as unknown as NumberConstructor; + Buffer.allocUnsafe = (() => { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + }) as typeof Buffer.allocUnsafe; + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + globalThis.Number = number; + Buffer.allocUnsafe = allocUnsafe; + } + expect(poisonCalls).toBe(0); + }); + it('bounds a payload that grows after its admitted size was checked', async () => { const stored = await fixture(); const raced = join(stored.directory, 'entry.ts'); From 8b38362d70d81508f483d7ab661fb5e423d2dab8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 13:10:06 -0700 Subject: [PATCH 35/80] fix(sdk): bind hosted descriptor reads Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 82 ++++++++++++++ packages/sdk/src/fs-descriptor.ts | 63 +++++++++++ packages/sdk/src/hosted-base-snapshot.ts | 107 +++++++++++------- packages/sdk/src/hosted-extension-runtime.ts | 3 +- packages/sdk/src/plugin-store.ts | 82 +++++++++----- .../sdk/tests/hosted-base-snapshot.test.ts | 57 +++++++++- .../sdk/tests/plugin-store-bounds.test.ts | 20 +++- 7 files changed, 326 insertions(+), 88 deletions(-) create mode 100644 packages/sdk/src/fs-descriptor.ts diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 72bbd8ba0..d849b09e3 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -524,3 +524,85 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 192 passed (192) Start at 12:48:17 Duration 2.03s (transform 1.23s, setup 0ms, collect 6.01s, tests 3.90s, environment 1ms, prepare 507ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 20ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 9ms + ✓ tests/preflight.test.ts (59 tests) 84ms + ✓ tests/hosted-base-snapshot.test.ts (10 tests) 1082ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1036ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 495ms + ✓ tests/authored-flow.test.ts (34 tests) 761ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 631ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 848ms + + Test Files 9 passed (9) + Tests 192 passed (192) + Start at 13:00:46 + Duration 2.05s (transform 1.23s, setup 0ms, collect 5.95s, tests 3.94s, environment 1ms, prepare 596ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 12ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 106ms + ✓ tests/hosted-base-snapshot.test.ts (11 tests) 949ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 895ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 410ms + ✓ tests/authored-flow.test.ts (34 tests) 773ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 623ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 829ms + + Test Files 9 passed (9) + Tests 193 passed (193) + Start at 13:09:12 + Duration 2.08s (transform 1.31s, setup 0ms, collect 6.15s, tests 3.73s, environment 2ms, prepare 659ms) diff --git a/packages/sdk/src/fs-descriptor.ts b/packages/sdk/src/fs-descriptor.ts new file mode 100644 index 000000000..f8fbdd28e --- /dev/null +++ b/packages/sdk/src/fs-descriptor.ts @@ -0,0 +1,63 @@ +import { + Dir, + Dirent, + Stats, + close, + fstat, + open, + read, + type BigIntStats, + type PathLike, +} from 'node:fs'; +import { promisify } from 'node:util'; + +const CLOSE = promisify(close) as (descriptor: number) => Promise; +const FSTAT = promisify(fstat) as ( + descriptor: number, options: { bigint: true }, +) => Promise; +const OPEN = promisify(open) as (path: PathLike, flags: number) => Promise; +const READ = promisify(read) as ( + descriptor: number, + buffer: Buffer, + offset: number, + length: number, + position: number, +) => Promise<{ bytesRead: number; buffer: Buffer }>; +const DIRENT_IS_DIRECTORY = Function.prototype.call.bind(Dirent.prototype.isDirectory) as ( + entry: Dirent, +) => boolean; +const DIRENT_IS_FILE = Function.prototype.call.bind(Dirent.prototype.isFile) as ( + entry: Dirent, +) => boolean; +const DIR_CLOSE = Function.prototype.call.bind(Dir.prototype.close) as ( + directory: Dir, +) => Promise; +const DIR_READ = Function.prototype.call.bind(Dir.prototype.read) as ( + directory: Dir, +) => Promise; +const STATS_IS_DIRECTORY = Function.prototype.call.bind(Stats.prototype.isDirectory) as ( + stats: Stats | BigIntStats, +) => boolean; +const STATS_IS_FILE = Function.prototype.call.bind(Stats.prototype.isFile) as ( + stats: Stats | BigIntStats, +) => boolean; + +export const closeDescriptor = CLOSE; +export const descriptorIsDirectory = STATS_IS_DIRECTORY; +export const descriptorIsFile = STATS_IS_FILE; +export const directoryEntryIsDirectory = DIRENT_IS_DIRECTORY; +export const directoryEntryIsFile = DIRENT_IS_FILE; +export const openDescriptor = OPEN; +export const readDirectoryEntry = DIR_READ; +export const closeDirectory = DIR_CLOSE; +export const statDescriptor = FSTAT; + +export async function readDescriptor( + descriptor: number, + buffer: Buffer, + offset: number, + length: number, + position: number, +): Promise { + return (await READ(descriptor, buffer, offset, length, position)).bytesRead; +} diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 5a1d506ea..9d1253247 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,9 +1,21 @@ import { constants } from 'node:fs'; -import { chmod, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; import { sha256 } from './bundle.js'; +import { + closeDescriptor, + closeDirectory, + descriptorIsDirectory, + descriptorIsFile, + directoryEntryIsDirectory, + directoryEntryIsFile, + openDescriptor, + readDescriptor, + readDirectoryEntry, + statDescriptor, +} from './fs-descriptor.js'; import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; @@ -139,8 +151,8 @@ async function readSnapshotTree(root: string): Promise { const entry = entries[index]!; const path = prefix === '' ? entry.name : join(prefix, entry.name); const absolute = join(directory, entry.name); - if (entry.isDirectory()) await visit(absolute, path); - else if (entry.isFile()) { + if (directoryEntryIsDirectory(entry)) await visit(absolute, path); + else if (directoryEntryIsFile(entry)) { const bytes = await readFile(absolute); ARRAY_PUSH(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); @@ -162,56 +174,63 @@ async function readTree( } const files: SourceFile[] = []; async function visit( - directory: Awaited>, + directory: number, relativeDirectory: string, depth: number, ): Promise { if (depth > MAX_DEPTH) throw tooLarge(); - const entries = await opendir(`/proc/self/fd/${directory.fd}`); - for await (const entry of entries) { - if (SET_HAS(EXCLUDED_DIRECTORIES, entry.name)) continue; - budget.entries += 1; - if (budget.entries > MAX_ENTRIES) throw tooLarge(); - const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); - const absolutePath = join(root, relativePath); - await hooks.beforeOpen?.(absolutePath); - const handle = await open(`/proc/self/fd/${directory.fd}/${entry.name}`, READ_FLAGS); - try { - const before = await handle.stat({ bigint: true }); - if (before.isDirectory()) { - await visit(handle, relativePath, depth + 1); - } else if (before.isFile()) { - if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); - await hooks.afterStat?.(absolutePath); - const bytes = await readBounded(handle, NUMBER(before.size), relativePath); - const after = await handle.stat({ bigint: true }); - if (after.size !== before.size || after.mtimeNs !== before.mtimeNs - || after.ctimeNs !== before.ctimeNs) { - throw invalid(`Hosted base source changed while reading "${relativePath}".`); + const entries = await opendir(`/proc/self/fd/${directory}`); + try { + for (;;) { + const entry = await readDirectoryEntry(entries); + if (entry === null) break; + if (SET_HAS(EXCLUDED_DIRECTORIES, entry.name)) continue; + budget.entries += 1; + if (budget.entries > MAX_ENTRIES) throw tooLarge(); + const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); + const absolutePath = join(root, relativePath); + await hooks.beforeOpen?.(absolutePath); + const descriptor = await openDescriptor(`/proc/self/fd/${directory}/${entry.name}`, READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (descriptorIsDirectory(before)) { + await visit(descriptor, relativePath, depth + 1); + } else if (descriptorIsFile(before)) { + if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); + const expectedBytes = NUMBER(before.size); + await hooks.afterStat?.(absolutePath); + const bytes = await readBounded(descriptor, expectedBytes, relativePath); + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs + || after.ctimeNs !== before.ctimeNs) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + budget.bytes += expectedBytes; + ARRAY_PUSH(files, OBJECT_FREEZE({ + path: prefix === '' ? relativePath : join(prefix, relativePath), + bytes, + sha256: sha256(bytes), + })); + } else { + throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); } - budget.bytes += bytes.byteLength; - ARRAY_PUSH(files, OBJECT_FREEZE({ - path: prefix === '' ? relativePath : join(prefix, relativePath), - bytes, - sha256: sha256(bytes), - })); - } else { - throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); + } finally { + await closeDescriptor(descriptor); } - } finally { - await handle.close(); } + } finally { + await closeDirectory(entries); } } try { - const rootHandle = await open(root, READ_FLAGS); + const rootDescriptor = await openDescriptor(root, READ_FLAGS); try { - if (!(await rootHandle.stat()).isDirectory()) { + if (!descriptorIsDirectory(await statDescriptor(rootDescriptor, { bigint: true }))) { throw invalid('Hosted base source root is not a directory.'); } - await visit(rootHandle, '', 0); + await visit(rootDescriptor, '', 0); } finally { - await rootHandle.close(); + await closeDescriptor(rootDescriptor); } } catch (error) { if (error instanceof PluginError) throw error; @@ -221,21 +240,21 @@ async function readTree( } async function readBounded( - handle: Awaited>, + descriptor: number, expectedBytes: number, relativePath: string, ): Promise { const bytes = BUFFER_ALLOC_UNSAFE(expectedBytes); let offset = 0; while (offset < expectedBytes) { - const result = await handle.read(bytes, offset, expectedBytes - offset, offset); - if (result.bytesRead === 0) { + const bytesRead = await readDescriptor(descriptor, bytes, offset, expectedBytes - offset, offset); + if (bytesRead === 0) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } - offset += result.bytesRead; + offset += bytesRead; } const extra = BUFFER_ALLOC_UNSAFE(1); - if ((await handle.read(extra, 0, 1, expectedBytes)).bytesRead !== 0) { + if (await readDescriptor(descriptor, extra, 0, 1, expectedBytes) !== 0) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } return bytes; diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 8be681c77..817241a78 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -3,6 +3,7 @@ import { readFile, realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; import { sha256 } from './bundle.js'; +import { descriptorIsFile } from './fs-descriptor.js'; import { createHostedBaseSnapshot, hostedBaseSourceDigest, @@ -280,7 +281,7 @@ function readBoundedDeclaration(path: string): Buffer { try { descriptor = openSync(path, DECLARATION_READ_FLAGS); const before = fstatSync(descriptor, { bigint: true }); - if (!before.isFile() || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); } const expected = NUMBER(before.size); diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index b7e075210..2aad06536 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -1,7 +1,19 @@ import { constants } from 'node:fs'; -import { lstat, mkdir, mkdtemp, open, opendir, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, mkdtemp, opendir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { payloadManifest, safePath, sha256 } from './bundle.js'; +import { + closeDescriptor, + closeDirectory, + descriptorIsDirectory, + descriptorIsFile, + directoryEntryIsDirectory, + directoryEntryIsFile, + openDescriptor, + readDescriptor, + readDirectoryEntry, + statDescriptor, +} from './fs-descriptor.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; @@ -98,8 +110,8 @@ async function regularFile( const absolute = join(root, path); const handle = await openStoredFile(root, path, hooks); try { - const before = await handle.stat({ bigint: true }); - if (!before.isFile() || before.size < 0n || before.size > BIG_INT(maxBytes) + const before = await statDescriptor(handle, { bigint: true }); + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(maxBytes) || (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes))) { throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); } @@ -108,24 +120,24 @@ async function regularFile( const bytes = BUFFER_ALLOC_UNSAFE(size); let offset = 0; while (offset < size) { - const result = await handle.read(bytes, offset, size - offset, offset); - if (result.bytesRead === 0) { + const bytesRead = await readDescriptor(handle, bytes, offset, size - offset, offset); + if (bytesRead === 0) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } - offset += result.bytesRead; + offset += bytesRead; } const extra = BUFFER_ALLOC_UNSAFE(1); - if ((await handle.read(extra, 0, 1, size)).bytesRead !== 0) { + if (await readDescriptor(handle, extra, 0, 1, size) !== 0) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } - const after = await handle.stat({ bigint: true }); + const after = await statDescriptor(handle, { bigint: true }); if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } return bytes; } finally { - await handle.close(); + await closeDescriptor(handle); } } @@ -133,38 +145,38 @@ async function openStoredFile( root: string, path: string, hooks: StoredPluginReadTestHooks, -): Promise>> { +): Promise { const parts = STRING_SPLIT(path, '/'); if (process.platform !== 'linux') { for (let i = 1; i < parts.length; i++) { let parent = root; for (let index = 0; index < i; index += 1) parent = join(parent, parts[index]!); - if (!(await lstat(parent)).isDirectory()) { + if (!descriptorIsDirectory(await lstat(parent))) { throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } } await hooks.beforeOpen?.(join(root, path)); - return await open(join(root, path), READ_FLAGS); + return await openDescriptor(join(root, path), READ_FLAGS); } - let directory = await open(resolve(root), READ_FLAGS); + let directory = await openDescriptor(resolve(root), READ_FLAGS); try { - if (!(await directory.stat()).isDirectory()) { + if (!descriptorIsDirectory(await statDescriptor(directory, { bigint: true }))) { throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`); } for (let index = 0; index < parts.length - 1; index += 1) { const part = parts[index]!; - const child = await open(`/proc/self/fd/${directory.fd}/${part}`, READ_FLAGS); - if (!(await child.stat()).isDirectory()) { - await child.close(); + const child = await openDescriptor(`/proc/self/fd/${directory}/${part}`, READ_FLAGS); + if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) { + await closeDescriptor(child); throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } - await directory.close(); + await closeDescriptor(directory); directory = child; } await hooks.beforeOpen?.(join(root, path)); - return await open(`/proc/self/fd/${directory.fd}/${parts[parts.length - 1]!}`, READ_FLAGS); + return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS); } finally { - await directory.close(); + await closeDescriptor(directory); } } @@ -185,7 +197,7 @@ async function readVerifiedStoredPluginFiles( const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); }; let manifest: Buffer; try { - if (!(await lstat(directory)).isDirectory()) return drift('not a directory'); + if (!descriptorIsDirectory(await lstat(directory))) return drift('not a directory'); manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); } const raw = BUFFER_TO_STRING(manifest, 'utf8'); @@ -239,16 +251,24 @@ async function rejectExtras(root: string, prefix: string, paths: Set, dr let entries = 0; async function visit(currentPrefix: string): Promise { const directory = await opendir(join(root, currentPrefix)); - for await (const entry of directory) { - entries += 1; - if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries'); - const path = currentPrefix + entry.name; - let declaredDescendant = false; - SET_FOR_EACH(paths, file => { - if (STRING_STARTS_WITH(file, `${path}/`)) declaredDescendant = true; - }); - if (entry.isDirectory() && declaredDescendant) await visit(`${path}/`); - else if (!entry.isFile() || !SET_HAS(paths, path)) drift(`${path}: unlisted file or unsupported file type`); + try { + for (;;) { + const entry = await readDirectoryEntry(directory); + if (entry === null) break; + entries += 1; + if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries'); + const path = currentPrefix + entry.name; + let declaredDescendant = false; + SET_FOR_EACH(paths, file => { + if (STRING_STARTS_WITH(file, `${path}/`)) declaredDescendant = true; + }); + if (directoryEntryIsDirectory(entry) && declaredDescendant) await visit(`${path}/`); + else if (!directoryEntryIsFile(entry) || !SET_HAS(paths, path)) { + drift(`${path}: unlisted file or unsupported file type`); + } + } + } finally { + await closeDirectory(directory); } } await visit(prefix); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 42f05fa41..fef9752fd 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { open as openFileHandle } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -106,13 +107,21 @@ describe('hosted base private snapshot', () => { const allocUnsafe = Buffer.allocUnsafe; let poisonCalls = 0; try { - globalThis.Number = (() => { - poisonCalls += 1; - throw new Error('ambient Number must not run'); + globalThis.Number = ((value?: unknown) => { + const stack = new Error().stack ?? ''; + if (stack.includes('hosted-base-snapshot.') || stack.includes('hosted-extension-runtime.')) { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + } + return number(value); }) as unknown as NumberConstructor; - Buffer.allocUnsafe = (() => { - poisonCalls += 1; - throw new Error('ambient Buffer.allocUnsafe must not run'); + Buffer.allocUnsafe = ((size: number) => { + const stack = new Error().stack ?? ''; + if (stack.includes('hosted-base-snapshot.') || stack.includes('hosted-extension-runtime.')) { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + } + return allocUnsafe(size); }) as typeof Buffer.allocUnsafe; await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ code: 'plugin_source_invalid', @@ -125,6 +134,42 @@ describe('hosted base private snapshot', () => { expect(poisonCalls).toBe(0); }); + it('reads through captured descriptors and charges admitted descriptor sizes', async () => { + const { project, flowPath } = fixture(); + const sample = await openFileHandle(flowPath, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(sample) as { + read: (...args: unknown[]) => unknown; + }; + const fileHandleRead = fileHandlePrototype.read; + await sample.close(); + const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype) as object; + const byteLength = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'byteLength')!; + let poisonCalls = 0; + try { + fileHandlePrototype.read = function poisonedRead(this: unknown, ...args: unknown[]) { + poisonCalls += 1; + return Reflect.apply(fileHandleRead, this, args); + }; + Object.defineProperty(typedArrayPrototype, 'byteLength', { + ...byteLength, + get(this: Uint8Array) { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('hosted-base-snapshot.')) { + poisonCalls += 1; + return 0; + } + return Reflect.apply(byteLength.get!, this, []); + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + fileHandlePrototype.read = fileHandleRead; + Object.defineProperty(typedArrayPrototype, 'byteLength', byteLength); + } + expect(poisonCalls).toBe(0); + }); + it('bounds a file that grows after its admitted size was checked', async () => { const { project } = fixture(); const raced = join(project, 'raced.bin'); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index b3b698ef1..67f360637 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -74,13 +74,21 @@ describe('bounded plugin-store verification', () => { const allocUnsafe = Buffer.allocUnsafe; let poisonCalls = 0; try { - globalThis.Number = (() => { - poisonCalls += 1; - throw new Error('ambient Number must not run'); + globalThis.Number = ((value?: unknown) => { + const stack = new Error().stack ?? ''; + if (stack.includes('plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + } + return number(value); }) as unknown as NumberConstructor; - Buffer.allocUnsafe = (() => { - poisonCalls += 1; - throw new Error('ambient Buffer.allocUnsafe must not run'); + Buffer.allocUnsafe = ((size: number) => { + const stack = new Error().stack ?? ''; + if (stack.includes('plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + } + return allocUnsafe(size); }) as typeof Buffer.allocUnsafe; await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); } finally { From 116f10fd57adfa6ddb7541f84ca81c64f617a320 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 13:32:15 -0700 Subject: [PATCH 36/80] fix(sdk): bind hosted filesystem authority Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 80 +++++++++ packages/sdk/src/hosted-base-snapshot.ts | 140 ++++++++++----- packages/sdk/src/hosted-extension-runtime.ts | 159 +++++++++------- packages/sdk/src/plugin-store.ts | 169 ++++++++++++------ .../sdk/tests/hosted-base-snapshot.test.ts | 125 +++++++++---- .../sdk/tests/plugin-store-bounds.test.ts | 32 ++-- 6 files changed, 492 insertions(+), 213 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index d849b09e3..d193d8c4f 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -606,3 +606,83 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 193 passed (193) Start at 13:09:12 Duration 2.08s (transform 1.31s, setup 0ms, collect 6.15s, tests 3.73s, environment 2ms, prepare 659ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 339ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 310ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/authored-flow.test.ts (34 tests) 744ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 607ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 782ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:29:45 + Duration 2.06s (transform 1.26s, setup 0ms, collect 6.37s, tests 3.08s, environment 2ms, prepare 635ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 364ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 334ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 366ms + ✓ tests/authored-flow.test.ts (34 tests) 714ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 692ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 829ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:31:40 + Duration 2.14s (transform 1.40s, setup 0ms, collect 6.71s, tests 3.11s, environment 1ms, prepare 510ms) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 9d1253247..4913146e9 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { chmod, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, opendir, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; @@ -20,9 +20,25 @@ import { findPluginProject } from './plugin-loader.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); +const CHMOD = chmod; +const MKDIR = mkdir; +const MKDTEMP = mkdtemp; +const OPENDIR = opendir; +const READDIR = readdir; +const REALPATH = realpath; +const RM = rm; +const WRITE_FILE = writeFile; +const TMPDIR = tmpdir; +const PATH_DIRNAME = dirname; +const PATH_IS_ABSOLUTE = isAbsolute; +const PATH_JOIN = join; +const PATH_RELATIVE = relative; +const PATH_RESOLVE = resolve; +const PATH_SEPARATOR = sep; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( - array: T[], compare?: (left: T, right: T) => number, + array: T[], + compare?: (left: T, right: T) => number, ) => T[]; const OBJECT_FREEZE = Object.freeze; const BIG_INT = BigInt; @@ -30,10 +46,12 @@ const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; const NUMBER = Number; const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; const STRING_LOCALE_COMPARE = Function.prototype.call.bind(String.prototype.localeCompare) as ( - value: string, other: string, + value: string, + other: string, ) => number; const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( - value: string, search: string, + value: string, + search: string, ) => boolean; const MAX_ENTRIES = 10_000; const MAX_BYTES = 64 * 1024 * 1024; @@ -67,6 +85,7 @@ export interface HostedBaseSnapshot { readonly liveDigest: string; readonly snapshotRoot: string; readonly snapshotFlowPath: string; + readonly snapshotFlowSha256: string; } /** @@ -75,41 +94,50 @@ export interface HostedBaseSnapshot { * represented in liveDigest and copied to the private identity directory. */ export async function createHostedBaseSnapshot(flowPath: string): Promise { - const origin = await realpath(resolve(flowPath)); - const discovered = findPluginProject(dirname(origin)) ?? dirname(origin); - const projectRoot = await realpath(discovered); - const flowRelative = relative(projectRoot, origin); - if (flowRelative === '' || isAbsolute(flowRelative) - || flowRelative === '..' || STRING_STARTS_WITH(flowRelative, `..${sep}`)) { + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const discovered = findPluginProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin); + const projectRoot = await REALPATH(discovered); + const flowRelative = PATH_RELATIVE(projectRoot, origin); + if ( + flowRelative === '' || + PATH_IS_ABSOLUTE(flowRelative) || + flowRelative === '..' || + STRING_STARTS_WITH(flowRelative, `..${PATH_SEPARATOR}`) + ) { throw invalid('Hosted base flow must be a file inside its project root.'); } - const liveSources = OBJECT_FREEZE([ - OBJECT_FREEZE({ root: projectRoot, prefix: '' }), - ]); + const liveSources = OBJECT_FREEZE([OBJECT_FREEZE({ root: projectRoot, prefix: '' })]); const files = await readAuthorityFiles(liveSources); const liveDigest = sourceDigest(files); - const snapshotRoot = await mkdtemp(join(tmpdir(), 'flows-hosted-base-')); - await chmod(snapshotRoot, 0o700); + const liveFlow = sourceFileAt(files, flowRelative); + if (liveFlow === undefined) throw invalid('Hosted base flow is missing from its admitted source.'); + const snapshotRoot = await MKDTEMP(PATH_JOIN(TMPDIR(), 'flows-hosted-base-')); + await CHMOD(snapshotRoot, 0o700); try { for (let index = 0; index < files.length; index += 1) { const file = files[index]!; - const target = join(snapshotRoot, file.path); - await mkdir(dirname(target), { recursive: true, mode: 0o700 }); - await writeFile(target, file.bytes, { flag: 'wx', mode: 0o400 }); + const target = PATH_JOIN(snapshotRoot, file.path); + await MKDIR(PATH_DIRNAME(target), { recursive: true, mode: 0o700 }); + await WRITE_FILE(target, file.bytes, { flag: 'wx', mode: 0o400 }); } const snapshotFiles = await readSnapshotTree(snapshotRoot); const snapshotDigest = sourceDigest(snapshotFiles); if (snapshotDigest !== liveDigest) { throw invalid('Hosted base private snapshot does not match its buffered source.'); } + const snapshotFlow = sourceFileAt(snapshotFiles, flowRelative); + if (snapshotFlow === undefined || snapshotFlow.sha256 !== liveFlow.sha256) { + throw invalid('Hosted base private snapshot does not contain its buffered flow source.'); + } return OBJECT_FREEZE({ liveSources, liveDigest, snapshotRoot, - snapshotFlowPath: join(snapshotRoot, flowRelative), + snapshotFlowPath: PATH_JOIN(snapshotRoot, flowRelative), + snapshotFlowSha256: snapshotFlow.sha256, }); } catch (error) { - await rm(snapshotRoot, { recursive: true, force: true }); + await RM(snapshotRoot, { recursive: true, force: true }); throw error; } } @@ -122,7 +150,7 @@ export async function hostedBaseSourceDigest( } export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Promise { - await rm(snapshot.snapshotRoot, { recursive: true, force: true }); + await RM(snapshot.snapshotRoot, { recursive: true, force: true }); } async function readAuthorityFiles( @@ -144,17 +172,34 @@ async function readAuthorityFiles( async function readSnapshotTree(root: string): Promise { const files: SourceFile[] = []; + const budget: SourceBudget = { entries: 0, bytes: 0 }; async function visit(directory: string, prefix: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); + const entries = await READDIR(directory, { withFileTypes: true }); ARRAY_SORT(entries, (left, right) => STRING_LOCALE_COMPARE(left.name, right.name)); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; - const path = prefix === '' ? entry.name : join(prefix, entry.name); - const absolute = join(directory, entry.name); + budget.entries += 1; + if (budget.entries > MAX_ENTRIES) throw tooLarge(); + const path = prefix === '' ? entry.name : PATH_JOIN(prefix, entry.name); + const absolute = PATH_JOIN(directory, entry.name); if (directoryEntryIsDirectory(entry)) await visit(absolute, path); else if (directoryEntryIsFile(entry)) { - const bytes = await readFile(absolute); - ARRAY_PUSH(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); + const descriptor = await openDescriptor(absolute, READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) + throw tooLarge(); + const expectedBytes = NUMBER(before.size); + const bytes = await readBounded(descriptor, expectedBytes, path); + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw invalid(`Hosted base snapshot changed while reading "${path}".`); + } + budget.bytes += expectedBytes; + ARRAY_PUSH(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); + } finally { + await closeDescriptor(descriptor); + } } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); } } @@ -173,13 +218,9 @@ async function readTree( throw invalid('Hosted base source snapshotting requires Linux.'); } const files: SourceFile[] = []; - async function visit( - directory: number, - relativeDirectory: string, - depth: number, - ): Promise { + async function visit(directory: number, relativeDirectory: string, depth: number): Promise { if (depth > MAX_DEPTH) throw tooLarge(); - const entries = await opendir(`/proc/self/fd/${directory}`); + const entries = await OPENDIR(`/proc/self/fd/${directory}`); try { for (;;) { const entry = await readDirectoryEntry(entries); @@ -187,8 +228,8 @@ async function readTree( if (SET_HAS(EXCLUDED_DIRECTORIES, entry.name)) continue; budget.entries += 1; if (budget.entries > MAX_ENTRIES) throw tooLarge(); - const relativePath = relativeDirectory === '' ? entry.name : join(relativeDirectory, entry.name); - const absolutePath = join(root, relativePath); + const relativePath = relativeDirectory === '' ? entry.name : PATH_JOIN(relativeDirectory, entry.name); + const absolutePath = PATH_JOIN(root, relativePath); await hooks.beforeOpen?.(absolutePath); const descriptor = await openDescriptor(`/proc/self/fd/${directory}/${entry.name}`, READ_FLAGS); try { @@ -201,16 +242,18 @@ async function readTree( await hooks.afterStat?.(absolutePath); const bytes = await readBounded(descriptor, expectedBytes, relativePath); const after = await statDescriptor(descriptor, { bigint: true }); - if (after.size !== before.size || after.mtimeNs !== before.mtimeNs - || after.ctimeNs !== before.ctimeNs) { + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } budget.bytes += expectedBytes; - ARRAY_PUSH(files, OBJECT_FREEZE({ - path: prefix === '' ? relativePath : join(prefix, relativePath), - bytes, - sha256: sha256(bytes), - })); + ARRAY_PUSH( + files, + OBJECT_FREEZE({ + path: prefix === '' ? relativePath : PATH_JOIN(prefix, relativePath), + bytes, + sha256: sha256(bytes), + }), + ); } else { throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); } @@ -239,11 +282,7 @@ async function readTree( return OBJECT_FREEZE(files); } -async function readBounded( - descriptor: number, - expectedBytes: number, - relativePath: string, -): Promise { +async function readBounded(descriptor: number, expectedBytes: number, relativePath: string): Promise { const bytes = BUFFER_ALLOC_UNSAFE(expectedBytes); let offset = 0; while (offset < expectedBytes) { @@ -254,7 +293,7 @@ async function readBounded( offset += bytesRead; } const extra = BUFFER_ALLOC_UNSAFE(1); - if (await readDescriptor(descriptor, extra, 0, 1, expectedBytes) !== 0) { + if ((await readDescriptor(descriptor, extra, 0, 1, expectedBytes)) !== 0) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } return bytes; @@ -273,6 +312,13 @@ function sourceDigest(files: readonly SourceFile[]): string { return sha256(canonicalize(records)); } +function sourceFileAt(files: readonly SourceFile[], path: string): SourceFile | undefined { + for (let index = 0; index < files.length; index += 1) { + if (files[index]!.path === path) return files[index]; + } + return undefined; +} + function invalid(message: string): PluginError { return new PluginError('plugin_source_invalid', message); } diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 817241a78..91ecfce64 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,8 +1,7 @@ import { constants, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'; -import { readFile, realpath } from 'node:fs/promises'; +import { realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; -import { sha256 } from './bundle.js'; import { descriptorIsFile } from './fs-descriptor.js'; import { createHostedBaseSnapshot, @@ -12,42 +11,51 @@ import { } from './hosted-base-snapshot.js'; import { PluginError } from './plugin-manifest.js'; import { findPluginProject } from './plugin-loader.js'; -import { - PLUGIN_LOCK_FILE, - PLUGIN_LOCK_VERSION, - parsePluginLock, - type PluginLockEntry, -} from './plugin-lock.js'; +import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, parsePluginLock, type PluginLockEntry } from './plugin-lock.js'; import { canonicalPluginRef, isGithubPluginRef, type PluginSourceRef } from './plugin-source.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; const INSTALLATION_AUTHORITY = new WeakSet(); const BASE_AUTHORITY = new WeakSet(); +const CLOSE_SYNC = closeSync; +const EXISTS_SYNC = existsSync; +const FSTAT_SYNC = fstatSync; +const OPEN_SYNC = openSync; +const READ_SYNC = readSync; +const REALPATH = realpath; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; +const ERROR = Error; const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; const MAX_DECLARATION_BYTES = 1024 * 1024; const BIG_INT = BigInt; const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( - value: Buffer, encoding: BufferEncoding, + value: Buffer, + encoding: BufferEncoding, ) => string; const NUMBER = Number; -const DECLARATION_READ_FLAGS = constants.O_RDONLY - | (constants.O_NOFOLLOW ?? 0) - | (constants.O_NONBLOCK ?? 0); +const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( - map: WeakMap, key: K, + map: WeakMap, + key: K, ) => V | undefined; const WEAK_MAP_SET = Function.prototype.call.bind(WeakMap.prototype.set) as ( - map: WeakMap, key: K, value: V, + map: WeakMap, + key: K, + value: V, ) => WeakMap; const WEAK_SET_ADD = Function.prototype.call.bind(WeakSet.prototype.add) as ( - set: WeakSet, value: T, + set: WeakSet, + value: T, ) => WeakSet; const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as ( - set: WeakSet, value: T, + set: WeakSet, + value: T, ) => boolean; interface RuntimeGeneration { @@ -85,7 +93,7 @@ export interface HostedExtensionRuntime { /** Load one base/installation generation that cannot be paired across redeploys. */ export async function loadHostedExtensionRuntime(flowPath: string): Promise { - const origin = await realpath(resolve(flowPath)); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); const loaded = await installationAt(origin, generation); generation.declarations = loaded.declarations; @@ -102,10 +110,8 @@ export async function loadHostedExtensionRuntime(flowPath: string): Promise { - const origin = await realpath(resolve(flowPath)); +export async function loadHostedExtensionArtifacts(flowPath: string): Promise { + const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); const loaded = await installationAt(origin, generation); generation.declarations = loaded.declarations; @@ -114,7 +120,7 @@ export async function loadHostedExtensionArtifacts( /** @internal Base-only test seam; public hosted callers use the combined loader. */ export async function loadHostedExtensionBase(flowPath: string): Promise { - const origin = await realpath(resolve(flowPath)); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); generation.declarations = declaredExtensions(origin).signature; return await baseAt(origin, generation); @@ -131,8 +137,11 @@ export async function assertHostedRuntimeAuthority( 'Hosted extension base authority is malformed; use loadHostedExtensionRuntime.', ); } - if (typeof installation !== 'object' || installation === null - || !WEAK_SET_HAS(INSTALLATION_AUTHORITY, installation)) { + if ( + typeof installation !== 'object' || + installation === null || + !WEAK_SET_HAS(INSTALLATION_AUTHORITY, installation) + ) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension installation authority is malformed; use loadHostedExtensionRuntime.', @@ -150,8 +159,12 @@ export async function assertHostedRuntimeAuthority( /** @internal Validate a metadata-only installation used by selection tests. */ export function assertHostedInstallationAuthority(value: unknown): asserts value is HostedExtensionInstallation { - if (typeof value !== 'object' || value === null || !WEAK_SET_HAS(INSTALLATION_AUTHORITY, value) - || !ARRAY_IS_ARRAY((value as Partial).artifacts)) { + if ( + typeof value !== 'object' || + value === null || + !WEAK_SET_HAS(INSTALLATION_AUTHORITY, value) || + !ARRAY_IS_ARRAY((value as Partial).artifacts) + ) { throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); } } @@ -159,11 +172,16 @@ export function assertHostedInstallationAuthority(value: unknown): asserts value async function installationAt( origin: string, generation: RuntimeGeneration, -): Promise<{ installation: HostedExtensionInstallation; declarations: string }> { - const root = findPluginProject(dirname(origin)); - if (root === undefined) return { - installation: installation([], generation), declarations: canonicalize([]), - }; +): Promise<{ + installation: HostedExtensionInstallation; + declarations: string; +}> { + const root = findPluginProject(PATH_DIRNAME(origin)); + if (root === undefined) + return { + installation: installation([], generation), + declarations: canonicalize([]), + }; const artifacts: HostedExtensionArtifact[] = []; const declared = hostedDeclaredExtensions(root); for (let index = 0; index < declared.length; index += 1) { @@ -186,17 +204,13 @@ async function installationAt( } function declaredExtensions(origin: string): { readonly signature: string } { - const root = findPluginProject(dirname(origin)); + const root = findPluginProject(PATH_DIRNAME(origin)); return { - signature: root === undefined - ? canonicalize([]) - : declarationSignature(hostedDeclaredExtensions(root)), + signature: root === undefined ? canonicalize([]) : declarationSignature(hostedDeclaredExtensions(root)), }; } -function declarationSignature( - declared: ReturnType, -): string { +function declarationSignature(declared: ReturnType): string { const records: Array<{ ref: string; name: string; @@ -220,12 +234,13 @@ function declarationSignature( function hostedDeclaredExtensions( root: string, ): readonly { ref: string; entry: PluginLockEntry; source: PluginSourceRef }[] { - if (existsSync(join(root, 'flows.json.tmp')) || existsSync(join(root, `${PLUGIN_LOCK_FILE}.tmp`))) { + if (EXISTS_SYNC(PATH_JOIN(root, 'flows.json.tmp')) || EXISTS_SYNC(PATH_JOIN(root, `${PLUGIN_LOCK_FILE}.tmp`))) { throw new PluginError('plugin_lock_invalid', 'Hosted extension declarations have a pending transaction.'); } let config: unknown; - try { config = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(join(root, 'flows.json')), 'utf8')); } - catch (error) { + try { + config = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(PATH_JOIN(root, 'flows.json')), 'utf8')); + } catch (error) { if (error instanceof PluginError) throw error; throw new PluginError('plugin_manifest_invalid', 'Invalid or oversized flows.json.'); } @@ -250,11 +265,12 @@ function hostedDeclaredExtensions( version: PLUGIN_LOCK_VERSION, plugins: OBJECT_FREEZE([]), }); - const lockPath = join(root, PLUGIN_LOCK_FILE); - if (existsSync(lockPath)) { + const lockPath = PATH_JOIN(root, PLUGIN_LOCK_FILE); + if (EXISTS_SYNC(lockPath)) { let value: unknown; - try { value = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(lockPath), 'utf8')); } - catch (error) { + try { + value = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(lockPath), 'utf8')); + } catch (error) { if (error instanceof PluginError) throw error; throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`); } @@ -263,7 +279,11 @@ function hostedDeclaredExtensions( if (declared.length !== lock.plugins.length) { throw new PluginError('plugin_lock_invalid', 'Hosted flows.json and flows.lock.json declarations differ.'); } - const result: Array<{ ref: string; entry: PluginLockEntry; source: PluginSourceRef }> = []; + const result: Array<{ + ref: string; + entry: PluginLockEntry; + source: PluginSourceRef; + }> = []; for (let index = 0; index < lock.plugins.length; index += 1) { const entry = lock.plugins[index]!; const source = OBJECT_FREEZE({ ...entry.source, ref: entry.source.sha }); @@ -279,8 +299,8 @@ function hostedDeclaredExtensions( function readBoundedDeclaration(path: string): Buffer { let descriptor: number | undefined; try { - descriptor = openSync(path, DECLARATION_READ_FLAGS); - const before = fstatSync(descriptor, { bigint: true }); + descriptor = OPEN_SYNC(path, DECLARATION_READ_FLAGS); + const before = FSTAT_SYNC(descriptor, { bigint: true }); if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); } @@ -288,21 +308,26 @@ function readBoundedDeclaration(path: string): Buffer { const bytes = BUFFER_ALLOC_UNSAFE(expected); let offset = 0; while (offset < expected) { - const count = readSync(descriptor, bytes, offset, expected - offset, offset); - if (count === 0) throw new Error('short read'); + const count = READ_SYNC(descriptor, bytes, offset, expected - offset, offset); + if (count === 0) throw new ERROR('short read'); offset += count; } - if (readSync(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) throw new Error('grew'); - const after = fstatSync(descriptor, { bigint: true }); + if (READ_SYNC(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) { + throw new ERROR('grew'); + } + const after = FSTAT_SYNC(descriptor, { bigint: true }); if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new Error('changed'); + throw new ERROR('changed'); } return bytes; } catch (error) { if (error instanceof PluginError) throw error; - throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is unreadable or changed while reading.'); + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension declaration is unreadable or changed while reading.', + ); } finally { - if (descriptor !== undefined) closeSync(descriptor); + if (descriptor !== undefined) CLOSE_SYNC(descriptor); } } @@ -317,12 +342,15 @@ function newGeneration(origin: string): RuntimeGeneration { async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { let currentSource: string; - try { currentSource = await hostedBaseSourceDigest(generation.sourceRoots); } - catch { + try { + currentSource = await hostedBaseSourceDigest(generation.sourceRoots); + } catch { throw new PluginError('plugin_source_invalid', 'Hosted extension runtime generation is no longer readable.'); } - if (declaredExtensions(generation.origin).signature !== generation.declarations - || currentSource !== generation.sourceSha256) { + if ( + declaredExtensions(generation.origin).signature !== generation.declarations || + currentSource !== generation.sourceSha256 + ) { throw new PluginError( 'plugin_source_invalid', 'Hosted extension runtime generation is stale; reload the base and installation together.', @@ -330,22 +358,21 @@ async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { +async function baseAt(origin: string, generation: RuntimeGeneration): Promise { const snapshot = await createHostedBaseSnapshot(origin); generation.sourceRoots = snapshot.liveSources; generation.sourceSha256 = snapshot.liveDigest; try { - const baseBytes = await readFile(snapshot.snapshotFlowPath); - if (sha256(baseBytes) !== SOFTWARE_FACTORY_SHA256) { + if (snapshot.snapshotFlowSha256 !== SOFTWARE_FACTORY_SHA256) { throw new PluginError( 'plugin_source_invalid', 'Hosted capability isolation accepts only the reviewed Software Factory base source.', ); } - const value = OBJECT_FREEZE({ name: 'software-factory', version: '2.0.22' }); + const value = OBJECT_FREEZE({ + name: 'software-factory', + version: '2.0.22', + }); WEAK_SET_ADD(BASE_AUTHORITY, value); WEAK_MAP_SET(BASE_GENERATION, value, generation); return value; diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 2aad06536..52b5fe3af 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -27,42 +27,56 @@ import { PluginError } from './plugin-manifest.js'; * refusal, not a surprise. */ export const PLUGIN_STORE = '.flows/plugins'; +const LSTAT = lstat; +const MKDIR = mkdir; +const MKDTEMP = mkdtemp; +const OPENDIR = opendir; +const RENAME = rename; +const RM = rm; +const WRITE_FILE = writeFile; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; +const ERROR = Error; const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; const ARRAY_IS_ARRAY = Array.isArray; const BIG_INT = BigInt; const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( - value: Buffer, encoding: BufferEncoding, + value: Buffer, + encoding: BufferEncoding, ) => string; const JSON_PARSE = JSON.parse; const NUMBER = Number; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; const OBJECT_FREEZE = Object.freeze; -const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( - pattern: RegExp, value: string, -) => boolean; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (pattern: RegExp, value: string) => boolean; const SET = Set; const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; const SET_FOR_EACH = Function.prototype.call.bind(Set.prototype.forEach) as ( - set: Set, callback: (value: T) => void, + set: Set, + callback: (value: T) => void, ) => void; const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( - value: string, separator: string, + value: string, + separator: string, ) => string[]; const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( - value: string, search: string, + value: string, + search: string, ) => boolean; -const READ_FLAGS = constants.O_RDONLY - | (constants.O_NOFOLLOW ?? 0) - | (constants.O_NONBLOCK ?? 0); +const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); export function pluginStoreDirectory(root: string, name: string, digest: string): string { - return join(resolve(root), PLUGIN_STORE, `${name}@sha256:${digest}`); + return PATH_JOIN(PATH_RESOLVE(root), PLUGIN_STORE, `${name}@sha256:${digest}`); } -export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array } +export interface StoredPluginFile { + readonly path: string; + readonly data: Uint8Array; +} /** @internal Deterministic race seams for the bounded store reader. */ export interface StoredPluginReadTestHooks { @@ -71,32 +85,51 @@ export interface StoredPluginReadTestHooks { } /** Write the files atomically; an existing directory is verified instead of overwritten. */ -export async function materializePlugin(root: string, name: string, files: readonly StoredPluginFile[]): Promise<{ directory: string; digest: string }> { +export async function materializePlugin( + root: string, + name: string, + files: readonly StoredPluginFile[], +): Promise<{ directory: string; digest: string }> { const manifest = payloadManifest(files); const digest = sha256(manifest); const directory = pluginStoreDirectory(root, name, digest); let exists = false; - try { await lstat(directory); exists = true; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - if (exists) { await verifyStoredPlugin(directory, digest); return { directory, digest }; } - const parent = dirname(directory); - await mkdir(parent, { recursive: true }); - const staging = await mkdtemp(join(parent, '.install-')); + try { + await LSTAT(directory); + exists = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (exists) { + await verifyStoredPlugin(directory, digest); + return { directory, digest }; + } + const parent = PATH_DIRNAME(directory); + await MKDIR(parent, { recursive: true }); + const staging = await MKDTEMP(PATH_JOIN(parent, '.install-')); try { for (let index = 0; index < files.length; index += 1) { const file = files[index]!; - if (!safePath(file.path) || file.path === 'manifest.json') throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`); - await mkdir(dirname(join(staging, file.path)), { recursive: true }); - await writeFile(join(staging, file.path), file.data, { mode: 0o644 }); + if (!safePath(file.path) || file.path === 'manifest.json') + throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`); + await MKDIR(PATH_DIRNAME(PATH_JOIN(staging, file.path)), { + recursive: true, + }); + await WRITE_FILE(PATH_JOIN(staging, file.path), file.data, { + mode: 0o644, + }); } - await writeFile(join(staging, 'manifest.json'), manifest); - try { await rename(staging, directory); } - catch (error) { + await WRITE_FILE(PATH_JOIN(staging, 'manifest.json'), manifest); + try { + await RENAME(staging, directory); + } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'EEXIST' && code !== 'ENOTEMPTY') throw error; await verifyStoredPlugin(directory, digest); } - } finally { await rm(staging, { recursive: true, force: true }); } + } finally { + await RM(staging, { recursive: true, force: true }); + } return { directory, digest }; } @@ -107,12 +140,16 @@ async function regularFile( expectedBytes?: number, hooks: StoredPluginReadTestHooks = {}, ): Promise { - const absolute = join(root, path); + const absolute = PATH_JOIN(root, path); const handle = await openStoredFile(root, path, hooks); try { const before = await statDescriptor(handle, { bigint: true }); - if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(maxBytes) - || (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes))) { + if ( + !descriptorIsFile(before) || + before.size < 0n || + before.size > BIG_INT(maxBytes) || + (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes)) + ) { throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); } await hooks.afterStat?.(absolute); @@ -127,12 +164,11 @@ async function regularFile( offset += bytesRead; } const extra = BUFFER_ALLOC_UNSAFE(1); - if (await readDescriptor(handle, extra, 0, 1, size) !== 0) { + if ((await readDescriptor(handle, extra, 0, 1, size)) !== 0) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } const after = await statDescriptor(handle, { bigint: true }); - if (after.size !== before.size || after.mtimeNs !== before.mtimeNs - || after.ctimeNs !== before.ctimeNs) { + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } return bytes; @@ -141,24 +177,20 @@ async function regularFile( } } -async function openStoredFile( - root: string, - path: string, - hooks: StoredPluginReadTestHooks, -): Promise { +async function openStoredFile(root: string, path: string, hooks: StoredPluginReadTestHooks): Promise { const parts = STRING_SPLIT(path, '/'); if (process.platform !== 'linux') { for (let i = 1; i < parts.length; i++) { let parent = root; - for (let index = 0; index < i; index += 1) parent = join(parent, parts[index]!); - if (!descriptorIsDirectory(await lstat(parent))) { + for (let index = 0; index < i; index += 1) parent = PATH_JOIN(parent, parts[index]!); + if (!descriptorIsDirectory(await LSTAT(parent))) { throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } } - await hooks.beforeOpen?.(join(root, path)); - return await openDescriptor(join(root, path), READ_FLAGS); + await hooks.beforeOpen?.(PATH_JOIN(root, path)); + return await openDescriptor(PATH_JOIN(root, path), READ_FLAGS); } - let directory = await openDescriptor(resolve(root), READ_FLAGS); + let directory = await openDescriptor(PATH_RESOLVE(root), READ_FLAGS); try { if (!descriptorIsDirectory(await statDescriptor(directory, { bigint: true }))) { throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`); @@ -173,7 +205,7 @@ async function openStoredFile( await closeDescriptor(directory); directory = child; } - await hooks.beforeOpen?.(join(root, path)); + await hooks.beforeOpen?.(PATH_JOIN(root, path)); return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS); } finally { await closeDescriptor(directory); @@ -194,32 +226,50 @@ async function readVerifiedStoredPluginFiles( expectedDigest: string, hooks: StoredPluginReadTestHooks, ): Promise { - const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); }; + const drift = (message: string): never => { + throw new PluginError('plugin_source_drift', `${directory}: ${message}`); + }; let manifest: Buffer; try { - if (!descriptorIsDirectory(await lstat(directory))) return drift('not a directory'); + if (!descriptorIsDirectory(await LSTAT(directory))) return drift('not a directory'); manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); - } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); } + } catch (error) { + return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); + } const raw = BUFFER_TO_STRING(manifest, 'utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); let entries: { path: string; sha256: string; bytes: number }[]; - try { entries = JSON_PARSE(raw); if (!ARRAY_IS_ARRAY(entries)) throw new Error(); } - catch { return drift('manifest.json is not a manifest'); } + try { + entries = JSON_PARSE(raw); + if (!ARRAY_IS_ARRAY(entries)) throw new ERROR(); + } catch { + return drift('manifest.json is not a manifest'); + } if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`); const paths = new SET(); const files = [{ path: 'manifest.json', data: manifest }]; let totalBytes = 0; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; - if (typeof entry?.path !== 'string' || !safePath(entry.path) - || typeof entry.sha256 !== 'string' || !REGEXP_TEST(/^[a-f0-9]{64}$/, entry.sha256) - || !NUMBER_IS_SAFE_INTEGER(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_PLUGIN_FILE_BYTES - || SET_HAS(paths, entry.path)) return drift('manifest.json lists an invalid file'); + if ( + typeof entry?.path !== 'string' || + !safePath(entry.path) || + typeof entry.sha256 !== 'string' || + !REGEXP_TEST(/^[a-f0-9]{64}$/, entry.sha256) || + !NUMBER_IS_SAFE_INTEGER(entry.bytes) || + entry.bytes < 0 || + entry.bytes > MAX_PLUGIN_FILE_BYTES || + SET_HAS(paths, entry.path) + ) + return drift('manifest.json lists an invalid file'); totalBytes += entry.bytes; if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`); let data: Buffer; - try { data = await regularFile(directory, entry.path, MAX_PLUGIN_FILE_BYTES, entry.bytes, hooks); } - catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); } + try { + data = await regularFile(directory, entry.path, MAX_PLUGIN_FILE_BYTES, entry.bytes, hooks); + } catch (error) { + return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); + } if (sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); SET_ADD(paths, entry.path); files[files.length] = { path: entry.path, data }; @@ -244,13 +294,18 @@ export async function readStoredPluginFiles( /** Drop a materialized plugin directory. Missing is a no-op. */ export async function removeStoredPlugin(directory: string): Promise { - await rm(directory, { recursive: true, force: true }); + await RM(directory, { recursive: true, force: true }); } -async function rejectExtras(root: string, prefix: string, paths: Set, drift: (m: string) => never): Promise { +async function rejectExtras( + root: string, + prefix: string, + paths: Set, + drift: (m: string) => never, +): Promise { let entries = 0; async function visit(currentPrefix: string): Promise { - const directory = await opendir(join(root, currentPrefix)); + const directory = await OPENDIR(PATH_JOIN(root, currentPrefix)); try { for (;;) { const entry = await readDirectoryEntry(directory); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index fef9752fd..ac669ff82 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { open as openFileHandle } from 'node:fs/promises'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -12,6 +13,7 @@ import { import { loadHostedExtensionRuntime } from '../src/hosted-extension-runtime.js'; const roots: string[] = []; +const require = createRequire(import.meta.url); afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); function fixture() { @@ -67,8 +69,7 @@ describe('hosted base private snapshot', () => { writeFileSync(join(dependency, 'index.js'), `throw new Error('must not execute');\n`); const snapshot = await createHostedBaseSnapshot(flowPath); try { - expect(() => readFileSync(join(snapshot.snapshotRoot, 'node_modules/local-identity/index.js'))) - .toThrow(); + expect(() => readFileSync(join(snapshot.snapshotRoot, 'node_modules/local-identity/index.js'))).toThrow(); const before = await hostedBaseSourceDigest(snapshot.liveSources); writeFileSync(join(dependency, 'index.js'), `throw new Error('replacement');\n`); expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(before); @@ -88,18 +89,16 @@ describe('hosted base private snapshot', () => { }); }); - it.each(['flows.json', 'flows.lock.json'])( - 'refuses oversized sparse %s before parsing it', async declaration => { - const { project, flowPath } = fixture(); - const path = join(project, declaration); - if (!existsSync(path)) writeFileSync(path, ''); - truncateSync(path, 1024 * 1024 + 1); - await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ - code: 'plugin_source_invalid', - message: expect.stringContaining('bounded regular file'), - }); - }, - ); + it.each(['flows.json', 'flows.lock.json'])('refuses oversized sparse %s before parsing it', async declaration => { + const { project, flowPath } = fixture(); + const path = join(project, declaration); + if (!existsSync(path)) writeFileSync(path, ''); + truncateSync(path, 1024 * 1024 + 1); + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('bounded regular file'), + }); + }); it('captures conversion and allocation across declarations and source snapshots', async () => { const { flowPath } = fixture(); @@ -109,7 +108,11 @@ describe('hosted base private snapshot', () => { try { globalThis.Number = ((value?: unknown) => { const stack = new Error().stack ?? ''; - if (stack.includes('hosted-base-snapshot.') || stack.includes('hosted-extension-runtime.')) { + const directCaller = stack.split('\n', 3)[2] ?? ''; + if ( + directCaller.includes('/src/hosted-base-snapshot.') || + directCaller.includes('/src/hosted-extension-runtime.') + ) { poisonCalls += 1; throw new Error('ambient Number must not run'); } @@ -117,7 +120,11 @@ describe('hosted base private snapshot', () => { }) as unknown as NumberConstructor; Buffer.allocUnsafe = ((size: number) => { const stack = new Error().stack ?? ''; - if (stack.includes('hosted-base-snapshot.') || stack.includes('hosted-extension-runtime.')) { + const directCaller = stack.split('\n', 3)[2] ?? ''; + if ( + directCaller.includes('/src/hosted-base-snapshot.') || + directCaller.includes('/src/hosted-extension-runtime.') + ) { poisonCalls += 1; throw new Error('ambient Buffer.allocUnsafe must not run'); } @@ -134,6 +141,56 @@ describe('hosted base private snapshot', () => { expect(poisonCalls).toBe(0); }); + it('keeps declaration and reviewed-base reads bound after builtin export synchronization', async () => { + const { flowPath } = fixture(); + const builtinFs = require('node:fs') as typeof import('node:fs'); + const originalOpenSync = builtinFs.openSync; + const originalReadFile = builtinFs.promises.readFile; + let poisonCalls = 0; + const directProductionCaller = (): boolean => { + const directCaller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + return directCaller.includes('/src/hosted-extension-runtime.'); + }; + try { + Object.defineProperty(builtinFs, 'openSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'openSync'), + value: (...args: unknown[]) => { + if (directProductionCaller()) { + poisonCalls += 1; + throw new Error('ambient openSync must not run'); + } + return Reflect.apply(originalOpenSync, builtinFs, args); + }, + }); + Object.defineProperty(builtinFs.promises, 'readFile', { + ...Object.getOwnPropertyDescriptor(builtinFs.promises, 'readFile'), + value: async (...args: unknown[]) => { + if (directProductionCaller()) { + poisonCalls += 1; + throw new Error('ambient readFile must not run'); + } + return await Reflect.apply(originalReadFile, builtinFs.promises, args); + }, + }); + syncBuiltinESMExports(); + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('reviewed Software Factory base source'), + }); + } finally { + Object.defineProperty(builtinFs, 'openSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'openSync'), + value: originalOpenSync, + }); + Object.defineProperty(builtinFs.promises, 'readFile', { + ...Object.getOwnPropertyDescriptor(builtinFs.promises, 'readFile'), + value: originalReadFile, + }); + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + it('reads through captured descriptors and charges admitted descriptor sizes', async () => { const { project, flowPath } = fixture(); const sample = await openFileHandle(flowPath, 'r'); @@ -174,11 +231,13 @@ describe('hosted base private snapshot', () => { const { project } = fixture(); const raced = join(project, 'raced.bin'); writeFileSync(raced, 'small'); - await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }], { - afterStat: async path => { - if (path === raced) truncateSync(raced, 64 * 1024 * 1024 + 1); - }, - })).rejects.toMatchObject({ + await expect( + hostedBaseSourceDigest([{ root: project, prefix: '' }], { + afterStat: async path => { + if (path === raced) truncateSync(raced, 64 * 1024 * 1024 + 1); + }, + }), + ).rejects.toMatchObject({ code: 'plugin_source_invalid', message: expect.stringContaining('changed while reading "raced.bin"'), }); @@ -193,17 +252,19 @@ describe('hosted base private snapshot', () => { let release: ReturnType | undefined; const started = Date.now(); try { - await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }], { - beforeOpen: async path => { - if (path !== raced) return; - rmSync(raced); - const result = spawnSync('/usr/bin/mkfifo', [raced]); - if (result.status !== 0) throw new Error(result.stderr.toString()); - // If O_NONBLOCK is removed, release the read-only open so the test - // fails on elapsed time instead of hanging the test process. - release = setTimeout(() => writeFileSync(raced, 'release'), 1_000); - }, - })).rejects.toMatchObject({ + await expect( + hostedBaseSourceDigest([{ root: project, prefix: '' }], { + beforeOpen: async path => { + if (path !== raced) return; + rmSync(raced); + const result = spawnSync('/usr/bin/mkfifo', [raced]); + if (result.status !== 0) throw new Error(result.stderr.toString()); + // If O_NONBLOCK is removed, release the read-only open so the test + // fails on elapsed time instead of hanging the test process. + release = setTimeout(() => writeFileSync(raced, 'release'), 1_000); + }, + }), + ).rejects.toMatchObject({ code: 'plugin_source_invalid', message: expect.stringContaining('unsupported entry "raced.txt"'), }); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 67f360637..cacef5bb0 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -25,8 +25,14 @@ describe('bounded plugin-store verification', () => { let calls = 0; let encoded: string | undefined; try { - Array.prototype.sort = (() => { calls += 1; return []; }) as typeof Array.prototype.sort; - Array.prototype.map = (() => { calls += 1; return []; }) as typeof Array.prototype.map; + Array.prototype.sort = (() => { + calls += 1; + return []; + }) as typeof Array.prototype.sort; + Array.prototype.map = (() => { + calls += 1; + return []; + }) as typeof Array.prototype.map; encoded = payloadManifest([ { path: 'z.ts', data: Buffer.from('z') }, { path: 'a.ts', data: Buffer.from('a') }, @@ -76,7 +82,8 @@ describe('bounded plugin-store verification', () => { try { globalThis.Number = ((value?: unknown) => { const stack = new Error().stack ?? ''; - if (stack.includes('plugin-store.')) { + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('/src/plugin-store.')) { poisonCalls += 1; throw new Error('ambient Number must not run'); } @@ -84,7 +91,8 @@ describe('bounded plugin-store verification', () => { }) as unknown as NumberConstructor; Buffer.allocUnsafe = ((size: number) => { const stack = new Error().stack ?? ''; - if (stack.includes('plugin-store.')) { + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('/src/plugin-store.')) { poisonCalls += 1; throw new Error('ambient Buffer.allocUnsafe must not run'); } @@ -101,13 +109,15 @@ describe('bounded plugin-store verification', () => { it('bounds a payload that grows after its admitted size was checked', async () => { const stored = await fixture(); const raced = join(stored.directory, 'entry.ts'); - await expect(readStoredPluginFiles(stored.directory, stored.digest, { - afterStat: async path => { - if (path === raced) { - writeFileSync(raced, Buffer.alloc(256_001)); - } - }, - })).rejects.toMatchObject({ + await expect( + readStoredPluginFiles(stored.directory, stored.digest, { + afterStat: async path => { + if (path === raced) { + writeFileSync(raced, Buffer.alloc(256_001)); + } + }, + }), + ).rejects.toMatchObject({ code: 'plugin_source_drift', message: expect.stringContaining('changed while reading'), }); From 9c3eedd7f8dd801a889d84afb35e31fff701ed91 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 13:40:37 -0700 Subject: [PATCH 37/80] test(sdk): track current surface fixture Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 40 +++++++++++++++++++ .../tests/hosted-extension-isolation.test.ts | 5 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index d193d8c4f..28fd86109 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -686,3 +686,43 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 194 passed (194) Start at 13:31:40 Duration 2.14s (transform 1.40s, setup 0ms, collect 6.71s, tests 3.11s, environment 1ms, prepare 510ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 10ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 562ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 500ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 97ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 363ms + ✓ tests/authored-flow.test.ts (34 tests) 714ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 623ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 783ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:40:02 + Duration 1.97s (transform 1.25s, setup 0ms, collect 5.97s, tests 3.17s, environment 1ms, prepare 572ms) diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index a6d61551b..9dbba2dd6 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -86,10 +86,13 @@ async function artifact(source = ordinaryExtension, value = manifest()): Promise function surfaceFixture(): string { const sourceRoot = resolve('../surface'); + const sourceManifest = JSON.parse(readFileSync(join(sourceRoot, 'package.json'), 'utf8')) as { + version: string; + }; const surfaceRoot = mkdtempSync(join(tmpdir(), 'hosted-surface-test-')); roots.push(surfaceRoot); writeFileSync(join(surfaceRoot, 'package.json'), JSON.stringify({ - name: '@relayflows/surface', version: '2.0.26', type: 'module', + name: '@relayflows/surface', version: sourceManifest.version, type: 'module', })); for (const file of [ 'flow.js', 'helpers/providers.js', 'provider-trigger.js', From 723bf71be6bbec279d8bbd378812ccdeab0c01ef Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 14:05:52 -0700 Subject: [PATCH 38/80] fix(sdk): close remaining hosted intrinsic seams Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 40 ++++++ packages/sdk/src/hosted-base-snapshot.ts | 9 +- packages/sdk/src/hosted-extension-runtime.ts | 18 +-- packages/sdk/src/hosted-extension-sandbox.ts | 133 +++++++++++------- packages/sdk/src/hosted-project.ts | 18 +++ packages/sdk/src/json-value.ts | 3 +- .../sdk/tests/hosted-base-snapshot.test.ts | 44 ++++++ .../tests/hosted-extension-isolation.test.ts | 42 ++++++ .../tests/hosted-extension-protocol.test.ts | 20 +++ 9 files changed, 264 insertions(+), 63 deletions(-) create mode 100644 packages/sdk/src/hosted-project.ts diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 28fd86109..e2c0a19c2 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -726,3 +726,43 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 194 passed (194) Start at 13:40:02 Duration 1.97s (transform 1.25s, setup 0ms, collect 5.97s, tests 3.17s, environment 1ms, prepare 572ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 17ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 410ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 365ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 495ms + ✓ tests/authored-flow.test.ts (34 tests) 762ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 661ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 881ms + + Test Files 9 passed (9) + Tests 196 passed (196) + Start at 14:04:23 + Duration 2.25s (transform 1.32s, setup 0ms, collect 6.81s, tests 3.35s, environment 1ms, prepare 609ms) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 4913146e9..4e5fc8eed 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -16,7 +16,7 @@ import { readDirectoryEntry, statDescriptor, } from './fs-descriptor.js'; -import { findPluginProject } from './plugin-loader.js'; +import { findHostedProject } from './hosted-project.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); @@ -93,9 +93,12 @@ export interface HostedBaseSnapshot { * node_modules is never linked or read. Every admitted source byte is * represented in liveDigest and copied to the private identity directory. */ -export async function createHostedBaseSnapshot(flowPath: string): Promise { +export async function createHostedBaseSnapshot( + flowPath: string, + projectRootOverride?: string, +): Promise { const origin = await REALPATH(PATH_RESOLVE(flowPath)); - const discovered = findPluginProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin); + const discovered = projectRootOverride ?? findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin); const projectRoot = await REALPATH(discovered); const flowRelative = PATH_RELATIVE(projectRoot, origin); if ( diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 91ecfce64..f9756611d 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -9,8 +9,8 @@ import { removeHostedBaseSnapshot, type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; +import { findHostedProject } from './hosted-project.js'; import { PluginError } from './plugin-manifest.js'; -import { findPluginProject } from './plugin-loader.js'; import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, parsePluginLock, type PluginLockEntry } from './plugin-lock.js'; import { canonicalPluginRef, isGithubPluginRef, type PluginSourceRef } from './plugin-source.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; @@ -60,6 +60,7 @@ const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as { const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); - generation.declarations = declaredExtensions(origin).signature; + generation.declarations = declaredExtensions(generation).signature; return await baseAt(origin, generation); } @@ -176,7 +177,7 @@ async function installationAt( installation: HostedExtensionInstallation; declarations: string; }> { - const root = findPluginProject(PATH_DIRNAME(origin)); + const root = generation.projectRoot; if (root === undefined) return { installation: installation([], generation), @@ -203,8 +204,8 @@ async function installationAt( }; } -function declaredExtensions(origin: string): { readonly signature: string } { - const root = findPluginProject(PATH_DIRNAME(origin)); +function declaredExtensions(generation: RuntimeGeneration): { readonly signature: string } { + const root = generation.projectRoot; return { signature: root === undefined ? canonicalize([]) : declarationSignature(hostedDeclaredExtensions(root)), }; @@ -334,6 +335,7 @@ function readBoundedDeclaration(path: string): Buffer { function newGeneration(origin: string): RuntimeGeneration { return { origin, + projectRoot: findHostedProject(PATH_DIRNAME(origin)), declarations: canonicalize([]), sourceRoots: OBJECT_FREEZE([]), sourceSha256: '', @@ -348,7 +350,7 @@ async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { - const snapshot = await createHostedBaseSnapshot(origin); + const snapshot = await createHostedBaseSnapshot(origin, generation.projectRoot ?? PATH_DIRNAME(origin)); generation.sourceRoots = snapshot.liveSources; generation.sourceSha256 = snapshot.liveDigest; try { diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 5bc1e703b..a3da05c20 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -3,7 +3,8 @@ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, parse, resolve } from 'node:path'; -import { spawn } from 'node:child_process'; +import { ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import type { Readable, Writable } from 'node:stream'; import { sha256 } from './bundle.js'; import { PluginError } from './plugin-manifest.js'; @@ -15,6 +16,28 @@ import { import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; +const CREATE_REQUIRE = createRequire; +const EXISTS_SYNC = existsSync; +const LSTAT_SYNC = lstatSync; +const READ_FILE_SYNC = readFileSync; +const REALPATH_SYNC = realpathSync; +const MKDIR = mkdir; +const MKDTEMP = mkdtemp; +const RM = rm; +const WRITE_FILE = writeFile; +const TMPDIR = tmpdir; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_PARSE = parse; +const PATH_RESOLVE = resolve; +const SPAWN = spawn; +const PROMISE = Promise; +const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( + emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, +) => EventEmitter; +const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( + child: ChildProcess, signal?: NodeJS.Signals | number, +) => boolean; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], ...values: T[]) => number; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; @@ -74,14 +97,14 @@ export async function runHostedExtensionSandbox( if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); } - const entryPath = resolve(options.artifactDirectory, options.entry); - if (!STRING_STARTS_WITH(entryPath, `${resolve(options.artifactDirectory)}/`)) { + const entryPath = PATH_RESOLVE(options.artifactDirectory, options.entry); + if (!STRING_STARTS_WITH(entryPath, `${PATH_RESOLVE(options.artifactDirectory)}/`)) { throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); } const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion, options.surfaceRoot); - const runtimeDirectory = await mkdtemp(join(tmpdir(), 'flows-hosted-extension-')); - const runner = join(runtimeDirectory, 'runner.mjs'); - const surfaceFacade = join(runtimeDirectory, 'surface'); + const runtimeDirectory = await MKDTEMP(PATH_JOIN(TMPDIR(), 'flows-hosted-extension-')); + const runner = PATH_JOIN(runtimeDirectory, 'runner.mjs'); + const surfaceFacade = PATH_JOIN(runtimeDirectory, 'surface'); try { const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); const payloadFiles: { path: string; data: Buffer }[] = []; @@ -96,69 +119,77 @@ export async function runHostedExtensionSandbox( 'Hosted extension changed while its isolated snapshot was created.', ); } - await writeFile(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); + await WRITE_FILE(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); await writeSurfaceFacade(surfaceFacade, surfaceRoot); const args = sandboxArguments({ node, runner, - extension: realpathSync(snapshot.directory), + extension: REALPATH_SYNC(snapshot.directory), surfaceFacade, }); await options.beforeLaunch?.(); const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; for (let index = 0; index < args.length; index += 1) commandArgs[commandArgs.length] = args[index]!; - const child = spawn(prlimit, commandArgs, { + const child = SPAWN(prlimit, commandArgs, { cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'], }); - return await exchangeHostedExtension( - child, - child.stdio[3] as Readable, - child.stdin as Writable, - child.stderr as Readable, - timeoutMs, - { - type: 'execute', - entry: `/extension/src/${options.entry}`, - surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', - capability: HOSTED_WRITE, - identity: options.identity, - input: options.input, - }, - options.invoke, - ); + const childClosed = child.exitCode !== null || child.signalCode !== null + ? new PROMISE(resolvePromise => resolvePromise()) + : new PROMISE(resolvePromise => { EVENT_ON(child, 'close', () => resolvePromise()); }); + try { + return await exchangeHostedExtension( + child, + child.stdio[3] as Readable, + child.stdin as Writable, + child.stderr as Readable, + timeoutMs, + { + type: 'execute', + entry: `/extension/src/${options.entry}`, + surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', + capability: HOSTED_WRITE, + identity: options.identity, + input: options.input, + }, + options.invoke, + ); + } finally { + if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); + await childClosed; + } } finally { - await rm(runtimeDirectory, { recursive: true, force: true }); + await RM(runtimeDirectory, { recursive: true, force: true }); } } function resolveSurfaceRoot(expectedVersion: string, override?: string): string { if (override !== undefined) return checkedSurfaceRoot(override, expectedVersion); let resolved: string; - try { resolved = realpathSync(createRequire(import.meta.url).resolve('@relayflows/surface')); } + try { resolved = REALPATH_SYNC(CREATE_REQUIRE(import.meta.url).resolve('@relayflows/surface')); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } - let directory = dirname(resolved); - const root = parse(directory).root; + let directory = PATH_DIRNAME(resolved); + const root = PATH_PARSE(directory).root; while (directory !== root) { - const packageJson = join(directory, 'package.json'); - if (existsSync(packageJson)) { + const packageJson = PATH_JOIN(directory, 'package.json'); + if (EXISTS_SYNC(packageJson)) { try { - const manifest = JSON_PARSE(readFileSync(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; + const manifest = JSON_PARSE(READ_FILE_SYNC(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { - return realpathSync(directory); + return REALPATH_SYNC(directory); } } catch { /* keep walking */ } } - directory = dirname(directory); + directory = PATH_DIRNAME(directory); } return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } function checkedSurfaceRoot(root: string, expectedVersion: string): string { let real: string; - try { real = realpathSync(root); } + try { real = REALPATH_SYNC(root); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } try { - const manifest = JSON_PARSE(readFileSync(join(real, 'package.json'), 'utf8')) as { + const manifest = JSON_PARSE(READ_FILE_SYNC(PATH_JOIN(real, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown; }; if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) return real; @@ -167,36 +198,36 @@ function checkedSurfaceRoot(root: string, expectedVersion: string): string { } async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promise { - await mkdir(join(directory, 'dist/helpers'), { recursive: true }); - await mkdir(join(directory, 'dist/triggers'), { recursive: true }); + await MKDIR(PATH_JOIN(directory, 'dist/helpers'), { recursive: true }); + await MKDIR(PATH_JOIN(directory, 'dist/triggers'), { recursive: true }); const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); const runtimeFiles: { file: string; bytes: Buffer; expected: string }[] = []; for (let index = 0; index < entries.length; index += 1) { const file = entries[index]![0]; const expected = entries[index]![1]; let bytes: Buffer; - try { bytes = readFileSync(join(surfaceRoot, 'dist', file)); } + try { bytes = READ_FILE_SYNC(PATH_JOIN(surfaceRoot, 'dist', file)); } catch { return unsupported(`hosted extension cannot read pinned Surface runtime ${file}`); } if (sha256(bytes) !== expected) { return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); } runtimeFiles[index] = { file, bytes, expected }; } - await writeFile(join(directory, 'package.json'), JSON_STRINGIFY({ + await WRITE_FILE(PATH_JOIN(directory, 'package.json'), JSON_STRINGIFY({ name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, }), { mode: 0o400, flag: 'wx' }); - await writeFile(join(directory, 'index.js'), + await WRITE_FILE(PATH_JOIN(directory, 'index.js'), "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", { mode: 0o400, flag: 'wx' }); - await writeFile(join(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", + await WRITE_FILE(PATH_JOIN(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", { mode: 0o400, flag: 'wx' }); for (let index = 0; index < runtimeFiles.length; index += 1) { const { file, bytes } = runtimeFiles[index]!; - await writeFile(join(directory, 'dist', file), bytes, { mode: 0o400, flag: 'wx' }); + await WRITE_FILE(PATH_JOIN(directory, 'dist', file), bytes, { mode: 0o400, flag: 'wx' }); } for (let index = 0; index < runtimeFiles.length; index += 1) { const { file, expected } = runtimeFiles[index]!; - if (sha256(readFileSync(join(directory, 'dist', file))) !== expected) { + if (sha256(READ_FILE_SYNC(PATH_JOIN(directory, 'dist', file))) !== expected) { throw new PluginError('plugin_source_drift', `Private Surface runtime snapshot changed at ${file}.`); } } @@ -212,16 +243,16 @@ export function sandboxArguments(input: { const libraryPaths = ['/usr/lib', '/usr/lib64', '/lib', '/lib64']; for (let index = 0; index < libraryPaths.length; index += 1) { const path = libraryPaths[index]!; - if (existsSync(path)) ARRAY_PUSH(args, '--ro-bind', realpathSync(path), path); + if (EXISTS_SYNC(path)) ARRAY_PUSH(args, '--ro-bind', REALPATH_SYNC(path), path); } ARRAY_PUSH(args, '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', '--dir', '/extension/node_modules/@relayflows/surface', - '--ro-bind', join(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', - '--ro-bind', join(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', - '--ro-bind', join(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', + '--ro-bind', PATH_JOIN(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', + '--ro-bind', PATH_JOIN(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', + '--ro-bind', PATH_JOIN(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', '--dir', '/extension/node_modules/@relayflows/surface/dist', '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', @@ -247,7 +278,7 @@ function surfaceRuntimeMounts(surfaceFacade: string): string[] { ARRAY_PUSH( mounts, '--ro-bind', - realpathSync(join(surfaceFacade, 'dist', file)), + REALPATH_SYNC(PATH_JOIN(surfaceFacade, 'dist', file)), `/extension/node_modules/@relayflows/surface/dist/${file}`, ); } @@ -257,9 +288,9 @@ function surfaceRuntimeMounts(surfaceFacade: string): string[] { function executable(path: string, name: string): string { let real: string; - try { real = realpathSync(path); } + try { real = REALPATH_SYNC(path); } catch { return unsupported(`${name} is unavailable`); } - if (!lstatSync(real).isFile()) return unsupported(`${name} is not a regular file`); + if (!LSTAT_SYNC(real).isFile()) return unsupported(`${name} is not a regular file`); return real; } diff --git a/packages/sdk/src/hosted-project.ts b/packages/sdk/src/hosted-project.ts new file mode 100644 index 000000000..26ac743b1 --- /dev/null +++ b/packages/sdk/src/hosted-project.ts @@ -0,0 +1,18 @@ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const EXISTS_SYNC = existsSync; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; + +/** Project discovery for hosted authority, captured before authored code runs. */ +export function findHostedProject(start: string): string | undefined { + let current = PATH_RESOLVE(start); + for (;;) { + if (EXISTS_SYNC(PATH_JOIN(current, 'flows.json'))) return current; + const parent = PATH_DIRNAME(current); + if (parent === current) return undefined; + current = parent; + } +} diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index 976ed2123..c9864a18c 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -4,6 +4,7 @@ const ARRAY_IS_ARRAY = Array.isArray; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_PROTOTYPE = Array.prototype; const ERROR = Error; +const IS_PROXY = isProxy; const JSON_STRINGIFY = JSON.stringify; const NUMBER = Number; const NUMBER_IS_FINITE = Number.isFinite; @@ -88,7 +89,7 @@ function snapshot( // Every ordinary reflective operation on a Proxy can execute author code. // Node and Bun expose this trap-free brand check, so reject before touching // its prototype, keys, descriptors, or identity collection. - if (isProxy(value)) throw nonJson(at, 'Proxy objects are not allowed'); + if (IS_PROXY(value)) throw nonJson(at, 'Proxy objects are not allowed'); if (WEAK_SET_HAS(ancestors, value)) throw nonJson(at, 'cycles are not allowed'); WEAK_SET_ADD(ancestors, value); try { diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index ac669ff82..59c6b4134 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -144,8 +144,13 @@ describe('hosted base private snapshot', () => { it('keeps declaration and reviewed-base reads bound after builtin export synchronization', async () => { const { flowPath } = fixture(); const builtinFs = require('node:fs') as typeof import('node:fs'); + const builtinPath = require('node:path') as typeof import('node:path'); const originalOpenSync = builtinFs.openSync; const originalReadFile = builtinFs.promises.readFile; + const originalExistsSync = builtinFs.existsSync; + const originalResolve = builtinPath.resolve; + const originalJoin = builtinPath.join; + const originalDirname = builtinPath.dirname; let poisonCalls = 0; const directProductionCaller = (): boolean => { const directCaller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; @@ -172,6 +177,32 @@ describe('hosted base private snapshot', () => { return await Reflect.apply(originalReadFile, builtinFs.promises, args); }, }); + Object.defineProperty(builtinFs, 'existsSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'existsSync'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-project.')) { + poisonCalls += 1; + throw new Error('ambient existsSync must not run'); + } + return Reflect.apply(originalExistsSync, builtinFs, args); + }, + }); + for (const [name, original] of [ + ['resolve', originalResolve], ['join', originalJoin], ['dirname', originalDirname], + ] as const) { + Object.defineProperty(builtinPath, name, { + ...Object.getOwnPropertyDescriptor(builtinPath, name), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-project.')) { + poisonCalls += 1; + throw new Error(`ambient path.${name} must not run`); + } + return Reflect.apply(original, builtinPath, args); + }, + }); + } syncBuiltinESMExports(); await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ code: 'plugin_source_invalid', @@ -186,6 +217,19 @@ describe('hosted base private snapshot', () => { ...Object.getOwnPropertyDescriptor(builtinFs.promises, 'readFile'), value: originalReadFile, }); + Object.defineProperty(builtinFs, 'existsSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'existsSync'), + value: originalExistsSync, + }); + Object.defineProperty(builtinPath, 'resolve', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'resolve'), value: originalResolve, + }); + Object.defineProperty(builtinPath, 'join', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'join'), value: originalJoin, + }); + Object.defineProperty(builtinPath, 'dirname', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'dirname'), value: originalDirname, + }); syncBuiltinESMExports(); } expect(poisonCalls).toBe(0); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 9dbba2dd6..d2463a654 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -9,6 +9,7 @@ import { writeFileSync, } from 'node:fs'; import { createServer } from 'node:http'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -22,6 +23,7 @@ import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js import { materializePlugin } from '../src/plugin-store.js'; const roots: string[] = []; +const require = createRequire(import.meta.url); afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); const manifest = (writes: string[] = ['cloud:babysitter-turn']) => ({ @@ -170,6 +172,46 @@ describe('hosted extension capability isolation', () => { } }]); }); + it.runIf(process.platform === 'linux')( + 'launches through the captured process primitive after builtin export synchronization', + async () => { + const builtinChildProcess = require('node:child_process') as typeof import('node:child_process'); + const originalSpawn = builtinChildProcess.spawn; + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let poisonCalls = 0; + try { + Object.defineProperty(builtinChildProcess, 'spawn', { + ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-sandbox.')) { + poisonCalls += 1; + throw new Error('ambient spawn must not run'); + } + return Reflect.apply(originalSpawn, builtinChildProcess, args); + }, + }); + syncBuiltinESMExports(); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-spawn', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + Object.defineProperty(builtinChildProcess, 'spawn', { + ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), value: originalSpawn, + }); + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }, + ); + it('mounts the verified snapshot when the live store is atomically replaced before bwrap', async () => { const installed = await artifact(); const wrapperRoot = mkdtempSync(join(tmpdir(), 'hosted-bwrap-wrapper-')); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts index d4346067f..d5a86b8d0 100644 --- a/packages/sdk/tests/hosted-extension-protocol.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -20,6 +21,7 @@ import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js import { materializePlugin } from '../src/plugin-store.js'; const roots: string[] = []; +const require = createRequire(import.meta.url); afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); const manifest = () => ({ @@ -154,6 +156,24 @@ describe('hosted extension hostile protocol', () => { expect(traps).toBe(0); }); + it('uses the captured proxy detector after builtin export synchronization', () => { + const builtinUtil = require('node:util') as typeof import('node:util'); + const originalIsProxy = builtinUtil.types.isProxy; + let poisonCalls = 0; + try { + builtinUtil.types.isProxy = (() => { + poisonCalls += 1; + return false; + }) as typeof builtinUtil.types.isProxy; + syncBuiltinESMExports(); + expect(() => snapshotJsonValue(new Proxy({}, {}), 'delivery')).toThrow(/Proxy objects are not allowed/); + } finally { + builtinUtil.types.isProxy = originalIsProxy; + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + it('shadows a poisoned Array.prototype.toJSON on copied arrays', () => { const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON'); let calls = 0; From 1edcc623aa094decdfb7ea24fefd24fc049d2a37 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 14:07:06 -0700 Subject: [PATCH 39/80] docs(evidence): record current-main isolation gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index e2c0a19c2..614e35ab7 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -766,3 +766,51 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 196 passed (196) Start at 14:04:23 Duration 2.25s (transform 1.32s, setup 0ms, collect 6.81s, tests 3.35s, environment 1ms, prepare 609ms) + +$ git merge-base HEAD origin/main +83addb00923adb02041f640d4e724ffaabcdd781 +$ git rev-parse origin/main +83addb00923adb02041f640d4e724ffaabcdd781 +$ git diff --exit-code origin/main -- extensions/babysitter +$ git diff --exit-code origin/main -- packages/sdk/src/authored-flow-executor.ts +$ cd packages/sdk +$ sha256sum ../../examples/software-factory/software-factory.flow.ts +49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6 ../../examples/software-factory/software-factory.flow.ts +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 10ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 567ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 513ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 475ms + ✓ tests/authored-flow.test.ts (34 tests) 765ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 648ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 892ms + + Test Files 9 passed (9) + Tests 196 passed (196) + Start at 14:06:25 + Duration 2.12s (transform 1.18s, setup 0ms, collect 6.21s, tests 3.48s, environment 2ms, prepare 501ms) From b44df3cf291cd6fea5279b995b620314f8ddf596 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 14:19:31 -0700 Subject: [PATCH 40/80] fix(sdk): close hosted launch and traversal gaps Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 80 +++++++++++++++++++ packages/sdk/src/hosted-extension-sandbox.ts | 11 ++- packages/sdk/src/plugin-store.ts | 17 ++-- .../tests/hosted-extension-isolation.test.ts | 3 + .../sdk/tests/plugin-store-bounds.test.ts | 17 +++- 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 614e35ab7..392eeb0db 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -814,3 +814,83 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 196 passed (196) Start at 14:06:25 Duration 2.12s (transform 1.18s, setup 0ms, collect 6.21s, tests 3.48s, environment 2ms, prepare 501ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 44ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 362ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 326ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 85ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 482ms + ✓ tests/authored-flow.test.ts (34 tests) 746ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 690ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 909ms + + Test Files 9 passed (9) + Tests 197 passed (197) + Start at 14:17:06 + Duration 2.21s (transform 1.27s, setup 0ms, collect 6.52s, tests 3.34s, environment 1ms, prepare 551ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 50ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 544ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 486ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 59ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 471ms + ✓ tests/authored-flow.test.ts (34 tests) 724ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 675ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 909ms + + Test Files 9 passed (9) + Tests 197 passed (197) + Start at 14:18:47 + Duration 2.15s (transform 1.28s, setup 0ms, collect 6.28s, tests 3.45s, environment 2ms, prepare 585ms) diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index a3da05c20..6b1be050d 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -31,6 +31,9 @@ const PATH_JOIN = join; const PATH_PARSE = parse; const PATH_RESOLVE = resolve; const SPAWN = spawn; +const PROCESS_EXEC_PATH = process.execPath; +const PROCESS_NODE_VERSION = process.versions.node; +const PROCESS_PLATFORM = process.platform; const PROMISE = Promise; const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, @@ -86,13 +89,13 @@ export interface RunHostedExtensionSandboxOptions { export async function runHostedExtensionSandbox( options: RunHostedExtensionSandboxOptions, ): Promise { - if (process.platform !== 'linux') return unsupported('hosted extension isolation requires Linux'); - if (options.nodePath === undefined && !supportsHostedSandboxFlags(process.versions.node)) { - return unsupported(`hosted extension isolation does not support Node ${process.versions.node}`); + if (PROCESS_PLATFORM !== 'linux') return unsupported('hosted extension isolation requires Linux'); + if (options.nodePath === undefined && !supportsHostedSandboxFlags(PROCESS_NODE_VERSION)) { + return unsupported(`hosted extension isolation does not support Node ${PROCESS_NODE_VERSION}`); } const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); const prlimit = executable(options.prlimitPath ?? '/usr/bin/prlimit', 'prlimit'); - const node = executable(options.nodePath ?? process.execPath, 'Node'); + const node = executable(options.nodePath ?? PROCESS_EXEC_PATH, 'Node'); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 52b5fe3af..7ce9b7134 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -82,6 +82,7 @@ export interface StoredPluginFile { export interface StoredPluginReadTestHooks { readonly beforeOpen?: (path: string) => Promise; readonly afterStat?: (path: string) => Promise; + readonly beforeDirectoryStat?: () => Promise; } /** Write the files atomically; an existing directory is verified instead of overwritten. */ @@ -198,12 +199,18 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea for (let index = 0; index < parts.length - 1; index += 1) { const part = parts[index]!; const child = await openDescriptor(`/proc/self/fd/${directory}/${part}`, READ_FLAGS); - if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) { - await closeDescriptor(child); - throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + let adopted = false; + try { + await hooks.beforeDirectoryStat?.(); + if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) { + throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + } + await closeDescriptor(directory); + directory = child; + adopted = true; + } finally { + if (!adopted) await closeDescriptor(child); } - await closeDescriptor(directory); - directory = child; } await hooks.beforeOpen?.(PATH_JOIN(root, path)); return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index d2463a654..34df1de77 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -177,6 +177,7 @@ describe('hosted extension capability isolation', () => { async () => { const builtinChildProcess = require('node:child_process') as typeof import('node:child_process'); const originalSpawn = builtinChildProcess.spawn; + const originalExecPath = process.execPath; const installed = await artifact(); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', @@ -195,6 +196,7 @@ describe('hosted extension capability isolation', () => { }, }); syncBuiltinESMExports(); + process.execPath = '/attacker-controlled-node'; await expect(runVerifiedNativeExtensionSandbox({ artifact: installed, manifest: validateFlowExtensionManifest(manifest()), @@ -203,6 +205,7 @@ describe('hosted extension capability isolation', () => { babysitterTurn: { queue: async () => ({ receiptId: 'receipt-spawn', status: 'queued' }) }, })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); } finally { + process.execPath = originalExecPath; Object.defineProperty(builtinChildProcess, 'spawn', { ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), value: originalSpawn, }); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index cacef5bb0..401a7d530 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -14,6 +14,7 @@ async function fixture() { const stored = await materializePlugin(root, 'bounded', [ { path: 'flows-plugin.json', data: Buffer.from('{"name":"bounded"}') }, { path: 'entry.ts', data: Buffer.from('export default {};') }, + { path: 'nested/value.ts', data: Buffer.from('export const value = 1;') }, ]); return stored; } @@ -122,4 +123,18 @@ describe('bounded plugin-store verification', () => { message: expect.stringContaining('changed while reading'), }); }); + + it.runIf(process.platform === 'linux')('closes a child descriptor when directory stat fails', async () => { + const stored = await fixture(); + const before = readdirSync('/proc/self/fd').length; + for (let attempt = 0; attempt < 50; attempt += 1) { + await expect(readStoredPluginFiles(stored.directory, stored.digest, { + beforeDirectoryStat: async () => { throw new Error('injected directory stat failure'); }, + })).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('nested/value.ts is missing'), + }); + } + expect(readdirSync('/proc/self/fd').length).toBeLessThanOrEqual(before + 2); + }); }); From 2639a4cd0756590ef574bbfcf83872950ebca905 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 14:32:31 -0700 Subject: [PATCH 41/80] fix(sdk): harden hosted serialization envelopes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 40 ++++++++++++++++++ packages/sdk/src/hosted-extension-protocol.ts | 23 ++++++++-- packages/sdk/src/hosted-extension-sandbox.ts | 9 ++-- .../tests/hosted-extension-isolation.test.ts | 37 ++++++++++++++++ ...sted-extension-protocol-intrinsics.test.ts | 42 +++++++++++++++++++ 5 files changed, 143 insertions(+), 8 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 392eeb0db..b8e7d5ecf 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -894,3 +894,43 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 197 passed (197) Start at 14:18:47 Duration 2.15s (transform 1.28s, setup 0ms, collect 6.28s, tests 3.45s, environment 2ms, prepare 585ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (4 tests) 12ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 27ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 561ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 501ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 452ms + ✓ tests/authored-flow.test.ts (34 tests) 766ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 637ms + ✓ tests/hosted-extension-isolation.test.ts (16 tests) 937ms + + Test Files 9 passed (9) + Tests 199 passed (199) + Start at 14:31:40 + Duration 2.14s (transform 1.24s, setup 0ms, collect 5.98s, tests 3.50s, environment 2ms, prepare 588ms) diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index f7a6dcc30..ab7740d3f 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -18,6 +18,7 @@ const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( ) => EventEmitter; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; +const OBJECT_CREATE = Object.create; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_KEYS = Object.keys; const OBJECT_FREEZE = Object.freeze; @@ -93,6 +94,7 @@ export async function exchangeHostedExtension( request: unknown, invoke: (request: unknown) => Promise, ): Promise { + const requestSnapshot = boundedJsonSnapshot(request, 'hosted extension request'); let buffer = ''; let stderrText = ''; let calls = 0; @@ -175,11 +177,11 @@ export async function exchangeHostedExtension( const snapshot = boundedJsonSnapshot(value, 'hosted capability result'); capabilityState = 'completed'; if (deferredProtocolError !== undefined) return finish(deferredProtocolError); - WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: true, value: snapshot })}\n`); + WRITABLE_WRITE(stdin, capabilityResultFrame(true, snapshot)); } catch (error) { capabilityError = failure(error); capabilityState = 'failed'; - WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + WRITABLE_WRITE(stdin, capabilityResultFrame(false)); finish(capabilityError); } }, @@ -187,7 +189,7 @@ export async function exchangeHostedExtension( if (settled) return; capabilityError = failure(error); capabilityState = 'failed'; - WRITABLE_WRITE(stdin, `${JSON_STRINGIFY({ type: 'capability-result', id: 1, ok: false, error: 'hosted capability refused' })}\n`); + WRITABLE_WRITE(stdin, capabilityResultFrame(false)); finish(capabilityError); }, ); @@ -222,10 +224,23 @@ export async function exchangeHostedExtension( + (stderrText === '' ? '' : `: ${stderrText}`), ); }); - WRITABLE_WRITE(stdin, `${JSON_STRINGIFY(request)}\n`); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY(requestSnapshot)}\n`); }); } +function capabilityResultFrame(ok: boolean, value?: unknown): string { + // JSON.stringify consults inherited `toJSON` before traversing an object. + // Protocol envelopes therefore never inherit from the ambient prototype; + // nested capability data is already a behavior-free bounded snapshot. + const envelope = OBJECT_CREATE(null) as Record; + envelope.type = 'capability-result'; + envelope.id = 1; + envelope.ok = ok; + if (ok) envelope.value = value; + else envelope.error = 'hosted capability refused'; + return `${JSON_STRINGIFY(envelope)}\n`; +} + function hasExactKeys(value: Record, keys: readonly string[]): boolean { if (OBJECT_KEYS(value).length !== keys.length) return false; for (let index = 0; index < keys.length; index += 1) { diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 6b1be050d..76dc4e8c6 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -43,7 +43,6 @@ const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.k ) => boolean; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], ...values: T[]) => number; const JSON_PARSE = JSON.parse; -const JSON_STRINGIFY = JSON.stringify; const OBJECT_ENTRIES = Object.entries; const OBJECT_FREEZE = Object.freeze; const OBJECT_KEYS = Object.keys; @@ -216,9 +215,11 @@ async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promi } runtimeFiles[index] = { file, bytes, expected }; } - await WRITE_FILE(PATH_JOIN(directory, 'package.json'), JSON_STRINGIFY({ - name: '@relayflows/surface', type: 'module', exports: { '.': './index.js', './runtime': './runtime.js' }, - }), { mode: 0o400, flag: 'wx' }); + await WRITE_FILE( + PATH_JOIN(directory, 'package.json'), + '{"name":"@relayflows/surface","type":"module","exports":{".":"./index.js","./runtime":"./runtime.js"}}', + { mode: 0o400, flag: 'wx' }, + ); await WRITE_FILE(PATH_JOIN(directory, 'index.js'), "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", { mode: 0o400, flag: 'wx' }); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 34df1de77..96b745ab7 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -516,6 +516,43 @@ export default flow('babysitter', async f => f.done('declined')) expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); }); + it.runIf(process.platform === 'linux')( + 'writes the Surface manifest and protocol without inherited toJSON behavior', + async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-to-json', + }); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: function poisonedToJSON(this: unknown) { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-')) { + poisonCalls += 1; + throw new Error('ambient Object.prototype.toJSON must not run'); + } + return this; + }, + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-to-json'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-to-json', status: 'queued' }) }, + timeoutMs: 3_000, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Object.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Object.prototype, 'toJSON', previous); + } + expect(poisonCalls).toBe(0); + }, + ); + it('fails closed when the handler omits or repeats the single capability call', async () => { for (const body of [ `f.done('success')`, diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts index bc8034197..01ebd08e8 100644 --- a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -165,3 +165,45 @@ it('registers and completes protocol I/O with captured stream operations', async expect(rejection).toMatchObject({ code: 'plugin_unsupported' }); expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 0 }); }); + +it('serializes protocol envelopes without inherited toJSON behavior', async () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON'); + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let poisonCalls = 0; + let adapterCalls = 0; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write('{"type":"result","completionReason":"success","capabilityCalls":1}\n'); + } + }); + + try { + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: () => { + poisonCalls += 1; + throw new Error('ambient Object.prototype.toJSON must not run'); + }, + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'execute', identity: { provider: 'github' } }, + async () => { + adapterCalls += 1; + return { receiptId: 'receipt-to-json', status: 'queued' }; + }, + ); + protocol.write('{"type":"capability","id":1,"name":"cloud:babysitter-turn","request":{"delivery":"exact"}}\n'); + await expect(run).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Object.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Object.prototype, 'toJSON', previous); + } + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 1 }); +}); From 6f5111801bbfe71f2e0364b554e508abd5a0dbea Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 14:44:12 -0700 Subject: [PATCH 42/80] fix(sdk): reject inherited hosted boundary behavior Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 40 +++++++++++++++++++ packages/sdk/src/hosted-base-snapshot.ts | 24 +++++++++-- packages/sdk/src/hosted-extension-protocol.ts | 5 ++- packages/sdk/src/plugin-store.ts | 30 ++++++++++---- .../sdk/tests/hosted-base-snapshot.test.ts | 26 ++++++++++++ ...sted-extension-protocol-intrinsics.test.ts | 37 +++++++++++++++++ .../sdk/tests/plugin-store-bounds.test.ts | 26 ++++++++++++ 7 files changed, 176 insertions(+), 12 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index b8e7d5ecf..b8d92dc8d 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -934,3 +934,43 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 199 passed (199) Start at 14:31:40 Duration 2.14s (transform 1.24s, setup 0ms, collect 5.98s, tests 3.50s, environment 2ms, prepare 588ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 51ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 569ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 513ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 93ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 463ms + ✓ tests/authored-flow.test.ts (34 tests) 742ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 646ms + ✓ tests/hosted-extension-isolation.test.ts (16 tests) 968ms + + Test Files 9 passed (9) + Tests 202 passed (202) + Start at 14:43:25 + Duration 2.16s (transform 1.18s, setup 0ms, collect 5.87s, tests 3.55s, environment 1ms, prepare 609ms) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 4e5fc8eed..f05b0aadf 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -40,7 +40,9 @@ const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( array: T[], compare?: (left: T, right: T) => number, ) => T[]; +const OBJECT_CREATE = Object.create; const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const BIG_INT = BigInt; const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; const NUMBER = Number; @@ -75,6 +77,10 @@ export interface HostedBaseSnapshotTestHooks { readonly afterStat?: (path: string) => Promise; } +const EMPTY_HOSTED_BASE_HOOKS = OBJECT_FREEZE( + OBJECT_CREATE(null), +) as HostedBaseSnapshotTestHooks; + export interface HostedBaseSourceRoot { readonly root: string; readonly prefix: string; @@ -147,7 +153,7 @@ export async function createHostedBaseSnapshot( export async function hostedBaseSourceDigest( sources: readonly HostedBaseSourceRoot[], - hooks: HostedBaseSnapshotTestHooks = {}, + hooks: HostedBaseSnapshotTestHooks = EMPTY_HOSTED_BASE_HOOKS, ): Promise { return sourceDigest(await readAuthorityFiles(sources, hooks)); } @@ -158,7 +164,7 @@ export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Pr async function readAuthorityFiles( sources: readonly HostedBaseSourceRoot[], - hooks: HostedBaseSnapshotTestHooks = {}, + hooks: HostedBaseSnapshotTestHooks = EMPTY_HOSTED_BASE_HOOKS, ): Promise { const budget: SourceBudget = { entries: 0, bytes: 0 }; const files: SourceFile[] = []; @@ -233,7 +239,7 @@ async function readTree( if (budget.entries > MAX_ENTRIES) throw tooLarge(); const relativePath = relativeDirectory === '' ? entry.name : PATH_JOIN(relativeDirectory, entry.name); const absolutePath = PATH_JOIN(root, relativePath); - await hooks.beforeOpen?.(absolutePath); + await ownHook(hooks, 'beforeOpen')?.(absolutePath); const descriptor = await openDescriptor(`/proc/self/fd/${directory}/${entry.name}`, READ_FLAGS); try { const before = await statDescriptor(descriptor, { bigint: true }); @@ -242,7 +248,7 @@ async function readTree( } else if (descriptorIsFile(before)) { if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); const expectedBytes = NUMBER(before.size); - await hooks.afterStat?.(absolutePath); + await ownHook(hooks, 'afterStat')?.(absolutePath); const bytes = await readBounded(descriptor, expectedBytes, relativePath); const after = await statDescriptor(descriptor, { bigint: true }); if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { @@ -285,6 +291,16 @@ async function readTree( return OBJECT_FREEZE(files); } +function ownHook( + hooks: HostedBaseSnapshotTestHooks, + name: keyof HostedBaseSnapshotTestHooks, +): ((path: string) => Promise) | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(hooks, name); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'function' + ? descriptor.value as (path: string) => Promise + : undefined; +} + async function readBounded(descriptor: number, expectedBytes: number, relativePath: string): Promise { const bytes = BUFFER_ALLOC_UNSAFE(expectedBytes); let offset = 0; diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index ab7740d3f..f926a676a 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -158,7 +158,10 @@ export async function exchangeHostedExtension( if (typeof parsed !== 'object' || parsed === null || ARRAY_IS_ARRAY(parsed)) { return refuse('Hosted extension emitted a non-object protocol frame.'); } - message = parsed as Record; + message = boundedJsonSnapshot( + parsed, + 'hosted extension protocol frame', + ) as Record; } catch { return refuse('Hosted extension emitted malformed protocol data.'); } if (message.type === 'capability') { diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 7ce9b7134..b2d5fe8d8 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -50,7 +50,9 @@ const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) const JSON_PARSE = JSON.parse; const NUMBER = Number; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const OBJECT_CREATE = Object.create; const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (pattern: RegExp, value: string) => boolean; const SET = Set; const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; @@ -85,6 +87,10 @@ export interface StoredPluginReadTestHooks { readonly beforeDirectoryStat?: () => Promise; } +const EMPTY_STORED_PLUGIN_HOOKS = OBJECT_FREEZE( + OBJECT_CREATE(null), +) as StoredPluginReadTestHooks; + /** Write the files atomically; an existing directory is verified instead of overwritten. */ export async function materializePlugin( root: string, @@ -139,7 +145,7 @@ async function regularFile( path: string, maxBytes: number, expectedBytes?: number, - hooks: StoredPluginReadTestHooks = {}, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, ): Promise { const absolute = PATH_JOIN(root, path); const handle = await openStoredFile(root, path, hooks); @@ -153,7 +159,7 @@ async function regularFile( ) { throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); } - await hooks.afterStat?.(absolute); + await ownHook(hooks, 'afterStat')?.(absolute); const size = NUMBER(before.size); const bytes = BUFFER_ALLOC_UNSAFE(size); let offset = 0; @@ -188,7 +194,7 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } } - await hooks.beforeOpen?.(PATH_JOIN(root, path)); + await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path)); return await openDescriptor(PATH_JOIN(root, path), READ_FLAGS); } let directory = await openDescriptor(PATH_RESOLVE(root), READ_FLAGS); @@ -201,7 +207,7 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea const child = await openDescriptor(`/proc/self/fd/${directory}/${part}`, READ_FLAGS); let adopted = false; try { - await hooks.beforeDirectoryStat?.(); + await ownHook(hooks, 'beforeDirectoryStat')?.(); if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) { throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } @@ -212,7 +218,7 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea if (!adopted) await closeDescriptor(child); } } - await hooks.beforeOpen?.(PATH_JOIN(root, path)); + await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path)); return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS); } finally { await closeDescriptor(directory); @@ -223,7 +229,7 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea export async function verifyStoredPlugin( directory: string, expectedDigest: string, - hooks: StoredPluginReadTestHooks = {}, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, ): Promise { await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); } @@ -294,11 +300,21 @@ async function readVerifiedStoredPluginFiles( export async function readStoredPluginFiles( directory: string, expectedDigest: string, - hooks: StoredPluginReadTestHooks = {}, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, ): Promise { return await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); } +function ownHook( + hooks: StoredPluginReadTestHooks, + name: keyof StoredPluginReadTestHooks, +): ((...args: unknown[]) => Promise) | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(hooks, name); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'function' + ? descriptor.value as (...args: unknown[]) => Promise + : undefined; +} + /** Drop a materialized plugin directory. Missing is a no-op. */ export async function removeStoredPlugin(directory: string): Promise { await RM(directory, { recursive: true, force: true }); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 59c6b4134..7c45286a5 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -271,6 +271,32 @@ describe('hosted base private snapshot', () => { expect(poisonCalls).toBe(0); }); + it('ignores inherited snapshot test hooks when production omits them', async () => { + const { project } = fixture(); + const names = ['beforeOpen', 'afterStat'] as const; + const previous = names.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + let poisonCalls = 0; + try { + for (const name of names) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: async () => { + poisonCalls += 1; + throw new Error(`inherited ${name} must not run`); + }, + }); + } + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + for (let index = 0; index < names.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (Object.prototype as Record)[names[index]!]; + else Object.defineProperty(Object.prototype, names[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + it('bounds a file that grows after its admitted size was checked', async () => { const { project } = fixture(); const raced = join(project, 'raced.bin'); diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts index 01ebd08e8..a685c3100 100644 --- a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -207,3 +207,40 @@ it('serializes protocol envelopes without inherited toJSON behavior', async () = } expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 1 }); }); + +it('rejects frames without consulting an inherited type discriminator', async () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'type'); + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let poisonCalls = 0; + let adapterCalls = 0; + try { + Object.defineProperty(Object.prototype, 'type', { + configurable: true, + get: () => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-protocol.')) poisonCalls += 1; + return 'capability'; + }, + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'execute' }, + async () => { + adapterCalls += 1; + return { receiptId: 'must-not-run', status: 'queued' }; + }, + ); + protocol.write('{}\n'); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } finally { + if (previous === undefined) delete (Object.prototype as { type?: unknown }).type; + else Object.defineProperty(Object.prototype, 'type', previous); + } + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 0 }); +}); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 401a7d530..f12be8797 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -107,6 +107,32 @@ describe('bounded plugin-store verification', () => { expect(poisonCalls).toBe(0); }); + it('ignores inherited store test hooks when production omits them', async () => { + const stored = await fixture(); + const names = ['beforeOpen', 'afterStat', 'beforeDirectoryStat'] as const; + const previous = names.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + let poisonCalls = 0; + try { + for (const name of names) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: async () => { + poisonCalls += 1; + throw new Error(`inherited ${name} must not run`); + }, + }); + } + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + for (let index = 0; index < names.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (Object.prototype as Record)[names[index]!]; + else Object.defineProperty(Object.prototype, names[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + it('bounds a payload that grows after its admitted size was checked', async () => { const stored = await fixture(); const raced = join(stored.directory, 'entry.ts'); From 40a64449d280482670d93b93e95b8811c5a60620 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:05:29 -0700 Subject: [PATCH 43/80] fix(sdk): stream immutable hosted sandbox bytes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 85 ++++++ .../sdk/src/hosted-extension-isolation.ts | 24 +- packages/sdk/src/hosted-extension-sandbox.ts | 271 ++++++++++-------- .../tests/hosted-extension-isolation.test.ts | 71 ++++- .../tests/hosted-extension-routing.test.ts | 29 +- 5 files changed, 328 insertions(+), 152 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index b8d92dc8d..43138b180 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -974,3 +974,88 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 202 passed (202) Start at 14:43:25 Duration 2.16s (transform 1.18s, setup 0ms, collect 5.87s, tests 3.55s, environment 1ms, prepare 609ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 64ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 562ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 501ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 94ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 391ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 570ms + ✓ tests/authored-flow.test.ts (34 tests) 747ms + ✓ tests/hosted-extension-isolation.test.ts (17 tests) 940ms + + Test Files 9 passed (9) + Tests 203 passed (203) + Start at 15:03:28 + Duration 2.19s (transform 1.38s, setup 0ms, collect 6.38s, tests 3.39s, environment 2ms, prepare 565ms) + +$ git rebase --autostash origin/main +Created autostash: 33c0e99c +Applied autostash. +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 44ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 566ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 505ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 85ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 471ms + ✓ tests/authored-flow.test.ts (34 tests) 703ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 619ms + ✓ tests/hosted-extension-isolation.test.ts (17 tests) 956ms + + Test Files 9 passed (9) + Tests 203 passed (203) + Start at 15:04:44 + Duration 2.24s (transform 1.32s, setup 0ms, collect 6.54s, tests 3.47s, environment 2ms, prepare 582ms) diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index 1e0923009..ddd2bd2cc 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -35,10 +35,15 @@ export type { const HOSTED_WRITE = 'cloud:babysitter-turn'; const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, + encoding: BufferEncoding, +) => string; const JSON_PARSE = JSON.parse; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; const OBJECT_HAS_OWN = Object.hasOwn; const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_KEYS = Object.keys; const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( regexp: RegExp, value: string, @@ -164,12 +169,12 @@ export async function runVerifiedNativeExtensionSandbox( surfaceVersion: versions.surface, identity, input: normalizedInput, - timeoutMs: options.timeoutMs, - bubblewrapPath: options.bubblewrapPath, - nodePath: options.nodePath, - prlimitPath: options.prlimitPath, - surfaceRoot: options.surfaceRoot, - beforeLaunch: options.beforeLaunch, + timeoutMs: ownOption(options, 'timeoutMs'), + bubblewrapPath: ownOption(options, 'bubblewrapPath'), + nodePath: ownOption(options, 'nodePath'), + prlimitPath: ownOption(options, 'prlimitPath'), + surfaceRoot: ownOption(options, 'surfaceRoot'), + beforeLaunch: ownOption<() => Promise>(options, 'beforeLaunch'), invoke: async request => babysitterReceipt(await options.babysitterTurn.queue( babysitterRequest(request, normalizedInput, options.dispatch), authority, @@ -228,7 +233,7 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise(options: object, name: string): T | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(options, name); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value as T : undefined; +} + function record(value: unknown, what: string): Record { if (typeof value !== 'object' || value === null || ARRAY_IS_ARRAY(value)) { throw new PluginError('plugin_event_unroutable', `${what} must be an object.`); diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 76dc4e8c6..0e09fadce 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -1,19 +1,18 @@ import { createRequire } from 'node:module'; import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { dirname, join, parse, resolve } from 'node:path'; import { ChildProcess, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import type { Readable, Writable } from 'node:stream'; -import { sha256 } from './bundle.js'; +import { Writable, type Readable } from 'node:stream'; +import { payloadManifest, sha256 } from './bundle.js'; +import { descriptorIsFile } from './fs-descriptor.js'; import { PluginError } from './plugin-manifest.js'; import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; import { exchangeHostedExtension, type HostedExtensionProtocolResult, } from './hosted-extension-protocol.js'; -import { materializePlugin, readStoredPluginFiles } from './plugin-store.js'; +import { readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const CREATE_REQUIRE = createRequire; @@ -21,11 +20,6 @@ const EXISTS_SYNC = existsSync; const LSTAT_SYNC = lstatSync; const READ_FILE_SYNC = readFileSync; const REALPATH_SYNC = realpathSync; -const MKDIR = mkdir; -const MKDTEMP = mkdtemp; -const RM = rm; -const WRITE_FILE = writeFile; -const TMPDIR = tmpdir; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_PARSE = parse; @@ -42,13 +36,28 @@ const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.k child: ChildProcess, signal?: NodeJS.Signals | number, ) => boolean; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], ...values: T[]) => number; +const BUFFER_FROM = Buffer.from; const JSON_PARSE = JSON.parse; const OBJECT_ENTRIES = Object.entries; const OBJECT_FREEZE = Object.freeze; -const OBJECT_KEYS = Object.keys; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING = String; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, separator: string, +) => string[]; const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( value: string, search: string, ) => boolean; +const WRITABLE_END = Function.prototype.call.bind(Writable.prototype.end) as ( + stream: Writable, +) => Writable; +const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as ( + stream: Writable, chunk: string | Uint8Array, +) => boolean; const DEFAULT_TIMEOUT_MS = 10_000; // These hard limits are inherited across prlimit -> bubblewrap -> Node and its // descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 @@ -66,6 +75,14 @@ const SURFACE_RUNTIME_SHA256 = OBJECT_FREEZE({ 'triggers.js': '4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c', 'triggers/github.js': 'e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b', }); +const SURFACE_PACKAGE_JSON = '{"name":"@relayflows/surface","type":"module","exports":{".":"./index.js","./runtime":"./runtime.js"}}'; +const SURFACE_INDEX = "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n"; +const SURFACE_RUNTIME = "export { getFlowDefinition } from './dist/flow.js';\n"; + +interface SandboxDataFile { + readonly destination: string; + readonly bytes: Uint8Array; +} export interface RunHostedExtensionSandboxOptions { readonly artifactDirectory: string; @@ -89,78 +106,92 @@ export async function runHostedExtensionSandbox( options: RunHostedExtensionSandboxOptions, ): Promise { if (PROCESS_PLATFORM !== 'linux') return unsupported('hosted extension isolation requires Linux'); - if (options.nodePath === undefined && !supportsHostedSandboxFlags(PROCESS_NODE_VERSION)) { + const nodeOverride = ownOption(options, 'nodePath'); + if (nodeOverride === undefined && !supportsHostedSandboxFlags(PROCESS_NODE_VERSION)) { return unsupported(`hosted extension isolation does not support Node ${PROCESS_NODE_VERSION}`); } - const bwrap = executable(options.bubblewrapPath ?? '/usr/bin/bwrap', 'bubblewrap'); - const prlimit = executable(options.prlimitPath ?? '/usr/bin/prlimit', 'prlimit'); - const node = executable(options.nodePath ?? PROCESS_EXEC_PATH, 'Node'); - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + const bwrap = executable(ownOption(options, 'bubblewrapPath') ?? '/usr/bin/bwrap', 'bubblewrap'); + const prlimit = executable(ownOption(options, 'prlimitPath') ?? '/usr/bin/prlimit', 'prlimit'); + const node = executable(nodeOverride ?? PROCESS_EXEC_PATH, 'Node'); + const timeoutMs = ownOption(options, 'timeoutMs') ?? DEFAULT_TIMEOUT_MS; + if (!NUMBER_IS_SAFE_INTEGER(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); } const entryPath = PATH_RESOLVE(options.artifactDirectory, options.entry); if (!STRING_STARTS_WITH(entryPath, `${PATH_RESOLVE(options.artifactDirectory)}/`)) { throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); } - const surfaceRoot = resolveSurfaceRoot(options.surfaceVersion, options.surfaceRoot); - const runtimeDirectory = await MKDTEMP(PATH_JOIN(TMPDIR(), 'flows-hosted-extension-')); - const runner = PATH_JOIN(runtimeDirectory, 'runner.mjs'); - const surfaceFacade = PATH_JOIN(runtimeDirectory, 'surface'); + const surfaceRoot = resolveSurfaceRoot( + options.surfaceVersion, + ownOption(options, 'surfaceRoot'), + ); + const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); + const payloadFiles: { path: string; data: Buffer }[] = []; + for (let index = 0; index < storedFiles.length; index += 1) { + const file = storedFiles[index]!; + if (file.path !== 'manifest.json') payloadFiles[payloadFiles.length] = file; + } + if (sha256(payloadManifest(payloadFiles)) !== options.artifactDigest) { + throw new PluginError( + 'plugin_source_drift', + 'Hosted extension changed while its isolated snapshot was created.', + ); + } + const dataFiles: SandboxDataFile[] = [{ + destination: '/runtime/runner.mjs', + bytes: BUFFER_FROM(HOSTED_EXTENSION_SANDBOX_SOURCE), + }]; + const surfaceFiles = readSurfaceFiles(surfaceRoot); + for (let index = 0; index < surfaceFiles.length; index += 1) { + dataFiles[dataFiles.length] = surfaceFiles[index]!; + } + for (let index = 0; index < payloadFiles.length; index += 1) { + const file = payloadFiles[index]!; + dataFiles[dataFiles.length] = { + destination: `/extension/src/${file.path}`, + bytes: file.data, + }; + } + const destinations: string[] = []; + for (let index = 0; index < dataFiles.length; index += 1) { + destinations[index] = dataFiles[index]!.destination; + } + const args = sandboxArguments({ node, dataDestinations: destinations }); + await ownOption<() => Promise>(options, 'beforeLaunch')?.(); + const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; + for (let index = 0; index < args.length; index += 1) commandArgs[commandArgs.length] = args[index]!; + const stdio: Array<'pipe' | 'ignore'> = ['pipe', 'ignore', 'pipe', 'pipe']; + for (let index = 0; index < dataFiles.length; index += 1) stdio[stdio.length] = 'pipe'; + const child = SPAWN(prlimit, commandArgs, { cwd: '/', env: {}, stdio }); + for (let index = 0; index < dataFiles.length; index += 1) { + const feed = child.stdio[index + 4] as Writable; + EVENT_ON(feed as unknown as EventEmitter, 'error', () => undefined); + WRITABLE_WRITE(feed, dataFiles[index]!.bytes); + WRITABLE_END(feed); + } + const childClosed = child.exitCode !== null || child.signalCode !== null + ? new PROMISE(resolvePromise => resolvePromise()) + : new PROMISE(resolvePromise => { EVENT_ON(child, 'close', () => resolvePromise()); }); try { - const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); - const payloadFiles: { path: string; data: Buffer }[] = []; - for (let index = 0; index < storedFiles.length; index += 1) { - const file = storedFiles[index]!; - if (file.path !== 'manifest.json') payloadFiles[payloadFiles.length] = file; - } - const snapshot = await materializePlugin(runtimeDirectory, 'hosted-extension', payloadFiles); - if (snapshot.digest !== options.artifactDigest) { - throw new PluginError( - 'plugin_source_drift', - 'Hosted extension changed while its isolated snapshot was created.', - ); - } - await WRITE_FILE(runner, HOSTED_EXTENSION_SANDBOX_SOURCE, { mode: 0o400, flag: 'wx' }); - await writeSurfaceFacade(surfaceFacade, surfaceRoot); - const args = sandboxArguments({ - node, - runner, - extension: REALPATH_SYNC(snapshot.directory), - surfaceFacade, - }); - await options.beforeLaunch?.(); - const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; - for (let index = 0; index < args.length; index += 1) commandArgs[commandArgs.length] = args[index]!; - const child = SPAWN(prlimit, commandArgs, { - cwd: '/', env: {}, stdio: ['pipe', 'ignore', 'pipe', 'pipe'], - }); - const childClosed = child.exitCode !== null || child.signalCode !== null - ? new PROMISE(resolvePromise => resolvePromise()) - : new PROMISE(resolvePromise => { EVENT_ON(child, 'close', () => resolvePromise()); }); - try { - return await exchangeHostedExtension( - child, - child.stdio[3] as Readable, - child.stdin as Writable, - child.stderr as Readable, - timeoutMs, - { - type: 'execute', - entry: `/extension/src/${options.entry}`, - surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', - capability: HOSTED_WRITE, - identity: options.identity, - input: options.input, - }, - options.invoke, - ); - } finally { - if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); - await childClosed; - } + return await exchangeHostedExtension( + child, + child.stdio[3] as Readable, + child.stdin as Writable, + child.stderr as Readable, + timeoutMs, + { + type: 'execute', + entry: `/extension/src/${options.entry}`, + surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', + capability: HOSTED_WRITE, + identity: options.identity, + input: options.input, + }, + options.invoke, + ); } finally { - await RM(runtimeDirectory, { recursive: true, force: true }); + if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); + await childClosed; } } @@ -199,11 +230,13 @@ function checkedSurfaceRoot(root: string, expectedVersion: string): string { return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } -async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promise { - await MKDIR(PATH_JOIN(directory, 'dist/helpers'), { recursive: true }); - await MKDIR(PATH_JOIN(directory, 'dist/triggers'), { recursive: true }); +function readSurfaceFiles(surfaceRoot: string): SandboxDataFile[] { const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); - const runtimeFiles: { file: string; bytes: Buffer; expected: string }[] = []; + const files: SandboxDataFile[] = [ + { destination: '/extension/node_modules/@relayflows/surface/package.json', bytes: BUFFER_FROM(SURFACE_PACKAGE_JSON) }, + { destination: '/extension/node_modules/@relayflows/surface/index.js', bytes: BUFFER_FROM(SURFACE_INDEX) }, + { destination: '/extension/node_modules/@relayflows/surface/runtime.js', bytes: BUFFER_FROM(SURFACE_RUNTIME) }, + ]; for (let index = 0; index < entries.length; index += 1) { const file = entries[index]![0]; const expected = entries[index]![1]; @@ -213,33 +246,17 @@ async function writeSurfaceFacade(directory: string, surfaceRoot: string): Promi if (sha256(bytes) !== expected) { return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); } - runtimeFiles[index] = { file, bytes, expected }; - } - await WRITE_FILE( - PATH_JOIN(directory, 'package.json'), - '{"name":"@relayflows/surface","type":"module","exports":{".":"./index.js","./runtime":"./runtime.js"}}', - { mode: 0o400, flag: 'wx' }, - ); - await WRITE_FILE(PATH_JOIN(directory, 'index.js'), - "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n", - { mode: 0o400, flag: 'wx' }); - await WRITE_FILE(PATH_JOIN(directory, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", - { mode: 0o400, flag: 'wx' }); - for (let index = 0; index < runtimeFiles.length; index += 1) { - const { file, bytes } = runtimeFiles[index]!; - await WRITE_FILE(PATH_JOIN(directory, 'dist', file), bytes, { mode: 0o400, flag: 'wx' }); - } - for (let index = 0; index < runtimeFiles.length; index += 1) { - const { file, expected } = runtimeFiles[index]!; - if (sha256(READ_FILE_SYNC(PATH_JOIN(directory, 'dist', file))) !== expected) { - throw new PluginError('plugin_source_drift', `Private Surface runtime snapshot changed at ${file}.`); - } + files[files.length] = { + destination: `/extension/node_modules/@relayflows/surface/dist/${file}`, + bytes, + }; } + return files; } /** @internal Pure construction seam for hostile-intrinsic regressions. */ export function sandboxArguments(input: { - node: string; runner: string; extension: string; surfaceFacade: string; + node: string; dataDestinations: readonly string[]; }): string[] { const args = [ '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', '--dir', '/usr', @@ -251,22 +268,40 @@ export function sandboxArguments(input: { } ARRAY_PUSH(args, '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', - '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--ro-bind', input.runner, '/runtime/runner.mjs', + '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', '--dir', '/extension/node_modules/@relayflows/surface', - '--ro-bind', PATH_JOIN(input.surfaceFacade, 'package.json'), '/extension/node_modules/@relayflows/surface/package.json', - '--ro-bind', PATH_JOIN(input.surfaceFacade, 'index.js'), '/extension/node_modules/@relayflows/surface/index.js', - '--ro-bind', PATH_JOIN(input.surfaceFacade, 'runtime.js'), '/extension/node_modules/@relayflows/surface/runtime.js', '--dir', '/extension/node_modules/@relayflows/surface/dist', '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', + '--dir', '/extension/src', ); - const runtimeMounts = surfaceRuntimeMounts(input.surfaceFacade); - for (let index = 0; index < runtimeMounts.length; index += 1) { - args[args.length] = runtimeMounts[index]!; + const directories = new SET(); + const fixedDirectories = [ + '/runtime', '/extension', '/extension/node_modules', '/extension/node_modules/@relayflows', + '/extension/node_modules/@relayflows/surface', '/extension/node_modules/@relayflows/surface/dist', + '/extension/node_modules/@relayflows/surface/dist/helpers', + '/extension/node_modules/@relayflows/surface/dist/triggers', '/extension/src', + ]; + for (let index = 0; index < fixedDirectories.length; index += 1) { + SET_ADD(directories, fixedDirectories[index]!); + } + for (let index = 0; index < input.dataDestinations.length; index += 1) { + const parts = STRING_SPLIT(input.dataDestinations[index]!, '/'); + let directory = ''; + for (let partIndex = 1; partIndex < parts.length - 1; partIndex += 1) { + directory += `/${parts[partIndex]!}`; + if (!SET_HAS(directories, directory)) { + SET_ADD(directories, directory); + ARRAY_PUSH(args, '--dir', directory); + } + } + } + for (let index = 0; index < input.dataDestinations.length; index += 1) { + ARRAY_PUSH(args, '--perms', '0400', '--ro-bind-data', STRING(index + 4), input.dataDestinations[index]!); } ARRAY_PUSH(args, - '--ro-bind', input.extension, '/extension/src', '--chdir', '/extension/src', + '--chdir', '/extension/src', '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', '--allow-fs-read=/runtime', '--allow-fs-read=/extension', '/runtime/runner.mjs', @@ -274,30 +309,20 @@ export function sandboxArguments(input: { return args; } -function surfaceRuntimeMounts(surfaceFacade: string): string[] { - const files = OBJECT_KEYS(SURFACE_RUNTIME_SHA256); - const mounts: string[] = []; - for (let index = 0; index < files.length; index += 1) { - const file = files[index]!; - ARRAY_PUSH( - mounts, - '--ro-bind', - REALPATH_SYNC(PATH_JOIN(surfaceFacade, 'dist', file)), - `/extension/node_modules/@relayflows/surface/dist/${file}`, - ); - } - return mounts; -} - function executable(path: string, name: string): string { let real: string; try { real = REALPATH_SYNC(path); } catch { return unsupported(`${name} is unavailable`); } - if (!LSTAT_SYNC(real).isFile()) return unsupported(`${name} is not a regular file`); + if (!descriptorIsFile(LSTAT_SYNC(real))) return unsupported(`${name} is not a regular file`); return real; } +function ownOption(options: object, name: string): T | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(options, name); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value as T : undefined; +} + export function supportsHostedSandboxFlags(version: string): boolean { const match = /^(\d+)\.(\d+)\.(\d+)(?:-|$)/.exec(version); if (match === null) return false; diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 96b745ab7..db7f0fa2e 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -4,6 +4,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -215,21 +216,85 @@ describe('hosted extension capability isolation', () => { }, ); - it('mounts the verified snapshot when the live store is atomically replaced before bwrap', async () => { + it.runIf(process.platform === 'linux')( + 'ignores inherited launcher overrides and decodes manifests with the captured Buffer intrinsic', + async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-options', + }); + const optionNames = [ + 'bubblewrapPath', 'nodePath', 'prlimitPath', 'surfaceRoot', 'timeoutMs', 'beforeLaunch', + ] as const; + const previousOptions = optionNames.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + const bufferToString = Buffer.prototype.toString; + let poisonCalls = 0; + try { + for (const name of optionNames) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: name === 'beforeLaunch' + ? async () => { poisonCalls += 1; throw new Error('inherited beforeLaunch must not run'); } + : name === 'timeoutMs' ? 1 : `/attacker/${name}`, + }); + } + Buffer.prototype.toString = function poisonedToString(this: Buffer, ...args: unknown[]) { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-extension-isolation.')) { + poisonCalls += 1; + throw new Error('ambient Buffer.toString must not run'); + } + return Reflect.apply(bufferToString, this, args as [BufferEncoding?]); + } as typeof Buffer.prototype.toString; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-options'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-options', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + Buffer.prototype.toString = bufferToString; + for (let index = 0; index < optionNames.length; index += 1) { + const descriptor = previousOptions[index]; + if (descriptor === undefined) { + delete (Object.prototype as Record)[optionNames[index]!]; + } else Object.defineProperty(Object.prototype, optionNames[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }, + ); + + it('streams verified bytes when the live store is replaced and no writable staging path exists', async () => { const installed = await artifact(); const wrapperRoot = mkdtempSync(join(tmpdir(), 'hosted-bwrap-wrapper-')); roots.push(wrapperRoot); const wrapper = join(wrapperRoot, 'bwrap-wrapper'); const replaced = `${installed.directory}.replaced`; const replacementSource = hostileImport([{ type: 'error', message: 'replacement executed' }]); + const existingStaging = new Set( + readdirSync(tmpdir()).filter(name => name.startsWith('flows-hosted-extension-')), + ); writeFileSync(wrapper, `#!${process.execPath} -const { mkdirSync, renameSync, writeFileSync } = require('node:fs'); +const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); const { spawnSync } = require('node:child_process'); +const existingStaging = new Set(${JSON.stringify([...existingStaging])}); +if (readdirSync(${JSON.stringify(tmpdir())}).some(name => name.startsWith('flows-hosted-extension-') && !existingStaging.has(name))) { + process.exit(91); +} renameSync(${JSON.stringify(installed.directory)}, ${JSON.stringify(replaced)}); mkdirSync(${JSON.stringify(installed.directory)}, { recursive: true }); writeFileSync(join(${JSON.stringify(installed.directory)}, 'babysitter.flow.ts'), ${JSON.stringify(replacementSource)}); -const child = spawnSync('/usr/bin/bwrap', process.argv.slice(2), { stdio: [0, 1, 2, 3] }); +const args = process.argv.slice(2); +let highestFd = 3; +for (let index = 0; index < args.length - 1; index += 1) { + if (args[index] === '--ro-bind-data') highestFd = Math.max(highestFd, Number(args[index + 1])); +} +const stdio = [0, 1, 2, 3]; +for (let fd = 4; fd <= highestFd; fd += 1) stdio[fd] = fd; +const child = spawnSync('/usr/bin/bwrap', args, { stdio }); if (child.error) throw child.error; process.exit(child.status ?? 1); `); diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index 79ba4315b..29505272a 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -1,7 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { github } from '@relayflows/surface'; import { extensionHandlerForHostedDispatch, @@ -11,9 +8,6 @@ import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; import { sandboxArguments, supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; -const roots: string[] = []; -afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); - describe('hosted extension routing policy', () => { it('accepts only Node releases that implement every sandbox flag', () => { expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); @@ -25,15 +19,13 @@ describe('hosted extension routing policy', () => { }); it('constructs the pinned Surface mounts without ambient array methods', () => { - const root = mkdtempSync(join(tmpdir(), 'hosted-sandbox-arguments-')); - roots.push(root); - const facade = join(root, 'surface'); - for (const file of [ + const surfaceFiles = [ 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', 'triggers.js', 'triggers/github.js', - ]) { - mkdirSync(join(facade, 'dist', file, '..'), { recursive: true }); - writeFileSync(join(facade, 'dist', file), file); + ]; + const dataDestinations = ['/runtime/runner.mjs', '/extension/src/babysitter.flow.ts']; + for (let index = 0; index < surfaceFiles.length; index += 1) { + dataDestinations[dataDestinations.length] = `/extension/node_modules/@relayflows/surface/dist/${surfaceFiles[index]!}`; } const flatMap = Array.prototype.flatMap; const push = Array.prototype.push; @@ -59,7 +51,7 @@ describe('hosted extension routing policy', () => { return Reflect.apply(push, this, values); } as typeof Array.prototype.push; args = sandboxArguments({ - node: '/trusted/node', runner: '/trusted/runner', extension: '/trusted/extension', surfaceFacade: facade, + node: '/trusted/node', dataDestinations, }); } finally { Array.prototype.flatMap = flatMap; @@ -67,12 +59,11 @@ describe('hosted extension routing policy', () => { } expect(calls).toBe(0); expect(args).not.toContain('/attacker'); - for (const file of [ - 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', - 'triggers.js', 'triggers/github.js', - ]) { + for (const file of surfaceFiles) { expect(args).toContain(`/extension/node_modules/@relayflows/surface/dist/${file}`); } + expect(args).toContain('--ro-bind-data'); + expect(args).not.toContain('/trusted/extension'); }); it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { From 25aa130dcd205e0371f6d2ad7116ce8b6c869fa3 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:13:01 -0700 Subject: [PATCH 44/80] fix(sdk): snapshot hosted runtime declarations Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 40 ++++++++++++++ packages/sdk/src/flow-extension-compat.ts | 15 ++++- packages/sdk/src/hosted-extension-runtime.ts | 5 +- .../tests/hosted-extension-isolation.test.ts | 55 +++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 43138b180..bbab473f0 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1059,3 +1059,43 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 203 passed (203) Start at 15:04:44 Duration 2.24s (transform 1.32s, setup 0ms, collect 6.54s, tests 3.47s, environment 2ms, prepare 582ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 53ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 639ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 576ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 454ms + ✓ tests/authored-flow.test.ts (34 tests) 752ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 608ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1055ms + + Test Files 9 passed (9) + Tests 204 passed (204) + Start at 15:12:20 + Duration 2.46s (transform 1.51s, setup 0ms, collect 7.22s, tests 3.68s, environment 2ms, prepare 629ms) diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 96722e23c..4a27df3f1 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -4,13 +4,24 @@ import { PluginError } from './plugin-manifest.js'; import { satisfiesRange } from './semver-range.js'; const JSON_PARSE = JSON.parse; +const OBJECT_FREEZE = Object.freeze; +const READ_FILE_SYNC = readFileSync; +const PACKAGE_JSON_URL = new URL('../package.json', import.meta.url); + +const packageManifest = JSON_PARSE(READ_FILE_SYNC(PACKAGE_JSON_URL, 'utf8')) as { + version: string; + dependencies: Record; +}; +const RUNTIME_VERSIONS = OBJECT_FREEZE({ + sdk: packageManifest.version, + surface: packageManifest.dependencies['@relayflows/surface']!, +}); export interface RuntimeVersions { readonly sdk: string; readonly surface: string } /** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */ export function runtimeVersions(): RuntimeVersions { - const pkg = JSON_PARSE(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record }; - return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! }; + return RUNTIME_VERSIONS; } /** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */ diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index f9756611d..f08a7e920 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -40,6 +40,7 @@ const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; +const OBJECT_HAS_OWN = Object.hasOwn; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( map: WeakMap, key: K, @@ -248,7 +249,9 @@ function hostedDeclaredExtensions( if (typeof config !== 'object' || config === null || ARRAY_IS_ARRAY(config)) { throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); } - const plugins = (config as { plugins?: unknown }).plugins; + const plugins = OBJECT_HAS_OWN(config, 'plugins') + ? (config as { plugins?: unknown }).plugins + : undefined; if (plugins !== undefined && !ARRAY_IS_ARRAY(plugins)) { throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); } diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index db7f0fa2e..f41bdebdd 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -177,7 +177,9 @@ describe('hosted extension capability isolation', () => { 'launches through the captured process primitive after builtin export synchronization', async () => { const builtinChildProcess = require('node:child_process') as typeof import('node:child_process'); + const builtinFs = require('node:fs') as typeof import('node:fs'); const originalSpawn = builtinChildProcess.spawn; + const originalReadFileSync = builtinFs.readFileSync; const originalExecPath = process.execPath; const installed = await artifact(); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ @@ -196,6 +198,17 @@ describe('hosted extension capability isolation', () => { return Reflect.apply(originalSpawn, builtinChildProcess, args); }, }); + Object.defineProperty(builtinFs, 'readFileSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'readFileSync'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/flow-extension-compat.')) { + poisonCalls += 1; + throw new Error('ambient runtime-version read must not run'); + } + return Reflect.apply(originalReadFileSync, builtinFs, args as Parameters); + }, + }); syncBuiltinESMExports(); process.execPath = '/attacker-controlled-node'; await expect(runVerifiedNativeExtensionSandbox({ @@ -210,12 +223,54 @@ describe('hosted extension capability isolation', () => { Object.defineProperty(builtinChildProcess, 'spawn', { ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), value: originalSpawn, }); + Object.defineProperty(builtinFs, 'readFileSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'readFileSync'), value: originalReadFileSync, + }); syncBuiltinESMExports(); } expect(poisonCalls).toBe(0); }, ); + it('requires flows.json to own its plugins declaration', async () => { + const installed = await artifact(); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), '{}'); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'plugins'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'plugins', { + configurable: true, + get: () => { + poisonCalls += 1; + return [installed.ref]; + }, + }); + await expect(loadHostedExtensionArtifacts(flowPath)).rejects.toMatchObject({ + code: 'plugin_lock_invalid', + message: expect.stringContaining('declarations differ'), + }); + } finally { + if (previous === undefined) delete (Object.prototype as { plugins?: unknown }).plugins; + else Object.defineProperty(Object.prototype, 'plugins', previous); + } + expect(poisonCalls).toBe(0); + }); + it.runIf(process.platform === 'linux')( 'ignores inherited launcher overrides and decodes manifests with the captured Buffer intrinsic', async () => { From fc8b34b238d52207f4659f5eb35cc144795f8091 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:16:00 -0700 Subject: [PATCH 45/80] docs(evidence): record rebased isolation gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index bbab473f0..0a136a9e9 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1099,3 +1099,101 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 204 passed (204) Start at 15:12:20 Duration 2.46s (transform 1.51s, setup 0ms, collect 7.22s, tests 3.68s, environment 2ms, prepare 629ms) + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +ba79ad5360dff49a0cc9f2d1cdf528f984d8b444 + +$ git rev-parse origin/main +9551018167e5bfcf248b7d2533401cf935ceac77 + +$ git merge-base HEAD origin/main +9551018167e5bfcf248b7d2533401cf935ceac77 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 58ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 411ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 373ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 4ms + ✓ tests/preflight.test.ts (67 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 398ms + ✓ tests/authored-flow.test.ts (34 tests) 736ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 640ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1012ms + + Test Files 9 passed (9) + Tests 212 passed (212) + Start at 15:14:56 + Duration 2.24s (transform 1.24s, setup 0ms, collect 5.99s, tests 3.38s, environment 1ms, prepare 542ms) + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +f25d5f7fa06c5b6b95e71ac107862bf40a7dcaa1 + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ git merge-base HEAD origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 49ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 593ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 534ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 106ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 377ms + ✓ tests/authored-flow.test.ts (34 tests) 773ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 659ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1068ms + + Test Files 9 passed (9) + Tests 212 passed (212) + Start at 15:16:23 + Duration 2.41s (transform 1.29s, setup 0ms, collect 6.81s, tests 3.64s, environment 2ms, prepare 660ms) From 4abe4e0050aa07b27a079908f6ce4bd22366d298 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:20:16 -0700 Subject: [PATCH 46/80] fix(sdk): bind hosted payload byte counts Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/bundle.ts | 5 +++- .../sdk/tests/plugin-store-bounds.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index 20c0448cd..990c4d071 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -18,6 +18,9 @@ const STRING_INCLUDES = Function.prototype.call.bind(String.prototype.includes) const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( value: string, separator: string, ) => string[]; +const TYPED_ARRAY_LENGTH = Function.prototype.call.bind( + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype) as object, 'length')!.get!, +) as (value: Uint8Array) => number; export interface BundleEntry { path: string; sha256: string; bytes: number } export interface BundleFile { path: string; data: Uint8Array | string; executable?: boolean } @@ -71,7 +74,7 @@ export function payloadManifest(files: readonly { path: string; data: Uint8Array const entries: { path: string; sha256: string; bytes: number }[] = []; for (let index = 0; index < sorted.length; index += 1) { const file = sorted[index]!; - entries[index] = { path: file.path, sha256: sha256(file.data), bytes: file.data.length }; + entries[index] = { path: file.path, sha256: sha256(file.data), bytes: TYPED_ARRAY_LENGTH(file.data) }; } return canonicalize(entries); } diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index f12be8797..15dc875db 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -46,6 +46,30 @@ describe('bounded plugin-store verification', () => { expect(JSON.parse(encoded!)).toMatchObject([{ path: 'a.ts' }, { path: 'z.ts' }]); }); + it('counts manifest bytes without the ambient typed-array length getter', () => { + const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype) as object; + const length = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'length')!; + const files = [{ path: 'entry.ts', data: Buffer.from('exact bytes') }]; + let poisonCalls = 0; + let encoded: string | undefined; + try { + Object.defineProperty(typedArrayPrototype, 'length', { + ...length, + get() { + poisonCalls += 1; + throw new Error('ambient typed-array length must not run'); + }, + }); + encoded = payloadManifest(files); + } finally { + Object.defineProperty(typedArrayPrototype, 'length', length); + } + expect(poisonCalls).toBe(0); + expect(JSON.parse(encoded!)).toEqual([ + { bytes: 11, path: 'entry.ts', sha256: 'e38e581aade78b64cc86f7ac9f3555ca78c2dcca747942a7f1d9b3275a834f75' }, + ]); + }); + it('refuses an oversized sparse payload before buffering it', async () => { const stored = await fixture(); truncateSync(join(stored.directory, 'entry.ts'), 256_001); From a234e411169a21c71dc1d3cf69bdf582d8ebfdd9 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:20:52 -0700 Subject: [PATCH 47/80] docs(evidence): record payload-boundary gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 0a136a9e9..f363c2469 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1197,3 +1197,49 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 212 passed (212) Start at 15:16:23 Duration 2.41s (transform 1.29s, setup 0ms, collect 6.81s, tests 3.64s, environment 2ms, prepare 660ms) + +$ git rev-parse HEAD +9d34380af04a843bac8187b0c4aaad3fc0498e53 + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ git merge-base HEAD origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 53ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 131ms + ✓ tests/authored-flow.test.ts (34 tests) 813ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 888ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 2209ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 2131ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 897ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1440ms + + Test Files 9 passed (9) + Tests 213 passed (213) + Start at 15:20:04 + Duration 3.58s (transform 2.46s, setup 0ms, collect 11.20s, tests 6.45s, environment 2ms, prepare 848ms) From bc36954377f83d8a3001172ed4844d53d0941c48 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:32:16 -0700 Subject: [PATCH 48/80] fix(sdk): pin hosted runtime versions at compile time Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-compat.ts | 18 +++++++----------- .../tests/hosted-extension-isolation.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 4a27df3f1..733d44755 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -1,20 +1,16 @@ -import { readFileSync } from 'node:fs'; import type { FlowExtensionManifest } from './flow-extension-manifest.js'; import { PluginError } from './plugin-manifest.js'; import { satisfiesRange } from './semver-range.js'; -const JSON_PARSE = JSON.parse; const OBJECT_FREEZE = Object.freeze; -const READ_FILE_SYNC = readFileSync; -const PACKAGE_JSON_URL = new URL('../package.json', import.meta.url); - -const packageManifest = JSON_PARSE(READ_FILE_SYNC(PACKAGE_JSON_URL, 'utf8')) as { - version: string; - dependencies: Record; -}; +// These are release pins, not runtime discovery. Reading package.json here +// would both consult mutable filesystem intrinsics after authored code and +// break a compiled Bun executable, where import.meta.url names the virtual +// executable rather than the installed SDK directory. A regression compares +// both literals with the package manifests so a version bump cannot drift. const RUNTIME_VERSIONS = OBJECT_FREEZE({ - sdk: packageManifest.version, - surface: packageManifest.dependencies['@relayflows/surface']!, + sdk: '2.0.27', + surface: '2.0.27', }); export interface RuntimeVersions { readonly sdk: string; readonly surface: string } diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index f41bdebdd..9acf24f44 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -20,6 +20,7 @@ import { runVerifiedNativeExtensionSandbox, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; +import { runtimeVersions } from '../src/flow-extension-compat.js'; import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; import { materializePlugin } from '../src/plugin-store.js'; @@ -121,6 +122,14 @@ function hostileImport(frames: readonly unknown[]): string { } describe('hosted extension capability isolation', () => { + it('keeps compile-time runtime pins synchronized with both package manifests', () => { + const sdk = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }; + const surface = JSON.parse(readFileSync(new URL('../../surface/package.json', import.meta.url), 'utf8')) as { + version: string; + }; + expect(runtimeVersions()).toEqual({ sdk: sdk.version, surface: surface.version }); + }); + it('resolves locked artifacts without importing extension top-level code', async () => { const marker = join(mkdtempSync(join(tmpdir(), 'hosted-loader-marker-')), 'imported'); roots.push(marker.slice(0, marker.lastIndexOf('/'))); From 611df0b02f02c9ccbba7e4acf0a5daa785a41776 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:33:00 -0700 Subject: [PATCH 49/80] docs(evidence): record standalone runtime fix Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index f363c2469..a302a0858 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1243,3 +1243,63 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 213 passed (213) Start at 15:20:04 Duration 3.58s (transform 2.46s, setup 0ms, collect 11.20s, tests 6.45s, environment 2ms, prepare 848ms) + +$ gh run view 35791890938 --repo AgentWorkforce/flows --log-failed +linux-x64-artifact Test SDK and type-level authoring contracts +ENOENT: no such file or directory, open '/$bunfs/package.json' +at packages/sdk/src/flow-extension-compat.ts (module-initialization package manifest read) +Process completed with exit code 1. + +$ git rev-parse HEAD +e4dadd131e1eac8997deb2dbb6a6ea96a3a37b3f + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 58ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 383ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 324ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 463ms + ✓ tests/authored-flow.test.ts (34 tests) 739ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 659ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 992ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:31:47 + Duration 2.32s (transform 1.43s, setup 0ms, collect 6.73s, tests 3.42s, environment 2ms, prepare 519ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.taZTPK/flows +Bundled 747 modules in 29ms +authored-node.mjs 2.89 MB (entry point) +[49ms] bundle 680 modules +[91ms] compile /tmp/flows-pr552-standalone.taZTPK/flows + +$ /tmp/flows-pr552-standalone.taZTPK/flows --help +Usage: +flows add +[complete CLI usage emitted; exit 0] From ae6ad50796ed1bb787e8f4b19d25e07706cacc83 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:35:27 -0700 Subject: [PATCH 50/80] fix(sdk): synchronize hosted runtime release pins Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-compat.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 733d44755..10e57f2d7 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -9,8 +9,8 @@ const OBJECT_FREEZE = Object.freeze; // executable rather than the installed SDK directory. A regression compares // both literals with the package manifests so a version bump cannot drift. const RUNTIME_VERSIONS = OBJECT_FREEZE({ - sdk: '2.0.27', - surface: '2.0.27', + sdk: '2.0.28', + surface: '2.0.28', }); export interface RuntimeVersions { readonly sdk: string; readonly surface: string } From f48e4caefe5dccd0049e4e0ddc15a857136e7962 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:36:13 -0700 Subject: [PATCH 51/80] docs(evidence): record v2.0.28 isolation gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index a302a0858..85cb047ee 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1303,3 +1303,69 @@ $ /tmp/flows-pr552-standalone.taZTPK/flows --help Usage: flows add [complete CLI usage emitted; exit 0] + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +db86507419f201565c9033b043404f897cbc4c9c + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ git merge-base HEAD origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/surface +$ bun run build +$ tsc + +$ cd ../sdk +$ npm install ../surface --no-save --ignore-scripts +changed 1 package, and audited 196 packages in 747ms + +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 42ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 558ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 508ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 604ms + ✓ tests/authored-flow.test.ts (34 tests) 774ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 946ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:35:08 + Duration 2.48s (transform 1.63s, setup 0ms, collect 8.09s, tests 3.51s, environment 1ms, prepare 537ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.t9SSsQ/flows +Bundled 779 modules in 33ms +authored-node.mjs 3.1 MB (entry point) +[50ms] bundle 712 modules +[88ms] compile /tmp/flows-pr552-standalone.t9SSsQ/flows + +$ /tmp/flows-pr552-standalone.t9SSsQ/flows --help >/dev/null +$ printf 'standalone-smoke-pass %s\n' /tmp/flows-pr552-standalone.t9SSsQ/flows +standalone-smoke-pass /tmp/flows-pr552-standalone.t9SSsQ/flows From 06a83cae061c13709ab8650cfb6c5df457deaaf4 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 00:32:08 +0200 Subject: [PATCH 52/80] fix(sdk): embed hosted runtime versions Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-compat.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 10e57f2d7..c94ea45e2 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -1,16 +1,15 @@ +import packageManifest from '../package.json' with { type: 'json' }; import type { FlowExtensionManifest } from './flow-extension-manifest.js'; import { PluginError } from './plugin-manifest.js'; import { satisfiesRange } from './semver-range.js'; const OBJECT_FREEZE = Object.freeze; -// These are release pins, not runtime discovery. Reading package.json here -// would both consult mutable filesystem intrinsics after authored code and -// break a compiled Bun executable, where import.meta.url names the virtual -// executable rather than the installed SDK directory. A regression compares -// both literals with the package manifests so a version bump cannot drift. +// This static import is resolved before module evaluation and embedded by the +// standalone Bun build. It neither consults mutable fs exports after authored +// code nor depends on import.meta.url naming a real package directory. const RUNTIME_VERSIONS = OBJECT_FREEZE({ - sdk: '2.0.28', - surface: '2.0.28', + sdk: packageManifest.version, + surface: packageManifest.dependencies['@relayflows/surface']!, }); export interface RuntimeVersions { readonly sdk: string; readonly surface: string } From d678570b2de9f1a95269f44ca88177ecada5e823 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:38:48 -0700 Subject: [PATCH 53/80] docs(evidence): record embedded-version gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 85cb047ee..05f2654f8 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1369,3 +1369,59 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.t9SSsQ/flows --help >/dev/null $ printf 'standalone-smoke-pass %s\n' /tmp/flows-pr552-standalone.t9SSsQ/flows standalone-smoke-pass /tmp/flows-pr552-standalone.t9SSsQ/flows + +$ git cherry-pick d125e673f19918ad339559602ff29183d93cbf2b +[feat/babysitter-gate8-isolation 7cf156fc] fix(sdk): embed hosted runtime versions + +$ git rev-parse HEAD +7cf156fcccc20ed51f3d75be6ec6c311b7061c96 + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 47ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 580ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 527ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 485ms + ✓ tests/authored-flow.test.ts (34 tests) 748ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 619ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 973ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:37:57 + Duration 2.61s (transform 1.70s, setup 0ms, collect 8.57s, tests 3.58s, environment 2ms, prepare 614ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.tPG8GP/flows +Bundled 780 modules in 47ms +authored-node.mjs 3.1 MB (entry point) +[81ms] bundle 713 modules +[134ms] compile /tmp/flows-pr552-standalone.tPG8GP/flows + +$ /tmp/flows-pr552-standalone.tPG8GP/flows --help >/dev/null +$ printf 'standalone-static-json-pass %s\n' /tmp/flows-pr552-standalone.tPG8GP/flows +standalone-static-json-pass /tmp/flows-pr552-standalone.tPG8GP/flows From 9588733c287737e5563d87922cc34295c601e9a6 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:50:28 -0700 Subject: [PATCH 54/80] fix(sdk): isolate hosted metadata parsing Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-compat.ts | 25 +- .../sdk/src/hosted-extension-isolation.ts | 5 +- packages/sdk/src/hosted-extension-manifest.ts | 247 ++++++++++++++++++ packages/sdk/src/hosted-extension-runtime.ts | 114 +++++++- packages/sdk/src/semver-range.ts | 53 +++- .../tests/hosted-extension-isolation.test.ts | 53 ++++ 6 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 packages/sdk/src/hosted-extension-manifest.ts diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index c94ea45e2..ad38f3db6 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -21,8 +21,18 @@ export function runtimeVersions(): RuntimeVersions { /** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */ export function assertCompatible(manifest: FlowExtensionManifest, versions: RuntimeVersions): void { - for (const [what, range, actual] of [['surface', manifest.compat.surface, versions.surface], ['sdk', manifest.compat.sdk, versions.sdk]] as const) { - if (!satisfiesRange(actual, range)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`); + assertRuntimeRange(manifest, 'surface', manifest.compat.surface, versions.surface); + assertRuntimeRange(manifest, 'sdk', manifest.compat.sdk, versions.sdk); +} + +function assertRuntimeRange( + manifest: FlowExtensionManifest, + what: 'surface' | 'sdk', + range: string, + actual: string, +): void { + if (!satisfiesRange(actual, range)) { + throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`); } } @@ -31,9 +41,16 @@ export function assertCompatible(manifest: FlowExtensionManifest, versions: Runt * optional: a base without it matches only `"*"`. */ export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void { - const entry = manifest.compat.base.find(b => b.name === base.name); + let entry: FlowExtensionManifest['compat']['base'][number] | undefined; + for (let index = 0; index < manifest.compat.base.length; index += 1) { + if (manifest.compat.base[index]!.name === base.name) entry = manifest.compat.base[index]; + } if (entry === undefined) { - throw new PluginError('plugin_incompatible', `${manifest.name} extends ${manifest.compat.base.map(b => b.name).join(', ')}, not "${base.name}".`); + let names = ''; + for (let index = 0; index < manifest.compat.base.length; index += 1) { + names += `${index === 0 ? '' : ', '}${manifest.compat.base[index]!.name}`; + } + throw new PluginError('plugin_incompatible', `${manifest.name} extends ${names}, not "${base.name}".`); } if (base.version === undefined) { if (entry.version !== '*') throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}, but the base flow declares no version; only "*" can be satisfied.`); diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index ddd2bd2cc..be01cea74 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -1,6 +1,7 @@ import { canonicalize } from './canonical.js'; import { sha256 } from './bundle.js'; -import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; +import type { FlowExtensionManifest } from './flow-extension-manifest.js'; +import { validateHostedFlowExtensionManifest } from './hosted-extension-manifest.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; import { hostedExtensionDispatchIdentity, @@ -235,7 +236,7 @@ async function verifiedManifest(artifact: HostedExtensionArtifact): Promise boolean; +const STRING_ENDS_WITH = Function.prototype.call.bind(String.prototype.endsWith) as ( + value: string, + search: string, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; +const STRING_TRIM = Function.prototype.call.bind(String.prototype.trim) as (value: string) => string; + +const TOP_LEVEL = [ + 'schema', 'kind', 'name', 'version', 'description', 'source', 'compat', 'entry', + 'extends', 'triggers', 'gates', 'verbs', 'permissions', 'preflight', 'config', +] as const; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/; +const WRITE_CLASS = /^[a-z0-9-]+(?::[a-z0-9_-]+)+$/; +const WALLCLOCK = /^\d+(?:ms|s|m|h|d)$/; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const VERSION_RANGE = /^(?:\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/; +const MAX_DESCRIPTION = 500; +const MAX_LIST = 64; + +/** + * Hosted manifests are parsed after authored code may have executed. Keep this + * projection deliberately narrower than the authoring validator and use only + * module-captured intrinsics. The only manifest that can launch is separately + * pinned by exact ref, payload digest, and manifest digest. + */ +export function validateHostedFlowExtensionManifest(input: unknown): FlowExtensionManifest { + let value: unknown; + try { + value = snapshotJsonValue(input, 'hosted plugin manifest'); + } catch { + return invalid('Hosted plugin manifest must be JSON data.'); + } + if (!record(value) || !hasOnlyKeys(value, TOP_LEVEL)) return invalid('Expected a hosted flow-extension manifest object.'); + if (value.schema !== 2 || value.kind !== 'flow-extension') return invalid('Hosted flow extensions require schema 2.'); + if (typeof value.name !== 'string' || !matches(NAME, value.name) || STRING_STARTS_WITH(value.name, 'helper-')) { + return invalid('name must be lowercase kebab-case and must not start with helper-.'); + } + if (typeof value.version !== 'string' || !matches(VERSION, value.version)) return invalid('version must be semver x.y.z.'); + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.length > MAX_DESCRIPTION)) { + return invalid(`description must be a string of at most ${MAX_DESCRIPTION} characters.`); + } + if (value.source !== undefined || value.config !== undefined) { + throw new PluginError('plugin_unsupported', 'Hosted capability extensions cannot declare source or config metadata.'); + } + if (typeof value.entry !== 'string' || !STRING_ENDS_WITH(value.entry, '.flow.ts') || !safePath(value.entry)) { + return invalid('entry must be a plugin-relative .flow.ts path.'); + } + const extension = extensionShape(value.extends); + if (!extension.handlers && extension.hooks.length === 0) { + return invalid('A flow extension must contribute handlers or at least one hook.'); + } + for (let index = 0; index < 2; index += 1) { + const field = index === 0 ? 'verbs' : 'gates'; + const at = value[field]; + if (at !== undefined && (!ARRAY_IS_ARRAY(at) || at.length !== 0)) { + return invalid(`A flow extension declares no ${field}; ship a helper plugin beside it.`); + } + } + const preflight = preflightShape(value.preflight); + const output = OBJECT_CREATE(null) as Record; + output.schema = 2; + output.kind = 'flow-extension'; + output.name = value.name; + output.version = value.version; + if (value.description !== undefined) output.description = value.description; + output.compat = compatShape(value.compat); + output.entry = value.entry; + output.extends = extension; + output.triggers = triggerShapes(value.triggers ?? []); + output.permissions = permissionsShape(value.permissions); + output.preflight = preflight; + return OBJECT_FREEZE(output) as unknown as FlowExtensionManifest; +} + +function extensionShape(value: unknown): FlowExtensionManifest['extends'] { + if (!record(value) || !hasOnlyKeys(value, ['handlers', 'hooks', 'verbs', 'gates']) + || typeof value.handlers !== 'boolean') { + return invalid('extends expects { handlers: boolean, hooks: [] }.'); + } + for (let index = 0; index < 2; index += 1) { + const field = index === 0 ? 'verbs' : 'gates'; + const at = value[field]; + if (at !== undefined && (!ARRAY_IS_ARRAY(at) || at.length !== 0)) { + return invalid(`A flow extension declares no ${field}; ship a helper plugin beside it.`); + } + } + return OBJECT_FREEZE({ handlers: value.handlers, hooks: stringList(value.hooks ?? [], 'extends.hooks', NAME) }); +} + +function compatShape(value: unknown): FlowExtensionCompat { + if (!record(value) || !hasOnlyKeys(value, ['surface', 'sdk', 'base']) + || typeof value.surface !== 'string' || !matches(VERSION_RANGE, value.surface) + || typeof value.sdk !== 'string' || !matches(VERSION_RANGE, value.sdk) + || !ARRAY_IS_ARRAY(value.base) || value.base.length === 0 || value.base.length > MAX_LIST) { + return invalid('compat expects surface, sdk, and at least one base.'); + } + const base: Array<{ readonly name: string; readonly version: string }> = []; + for (let index = 0; index < value.base.length; index += 1) { + const entry = value.base[index]; + if (!record(entry) || !hasOnlyKeys(entry, ['name', 'version']) + || typeof entry.name !== 'string' || STRING_TRIM(entry.name).length === 0 || entry.name.length > 100 + || typeof entry.version !== 'string' || !matches(VERSION_RANGE, entry.version)) { + return invalid('compat.base entries are { name, version range }.'); + } + for (let prior = 0; prior < base.length; prior += 1) { + if (base[prior]!.name === entry.name) return invalid('compat.base names a base flow twice.'); + } + base[base.length] = OBJECT_FREEZE({ name: entry.name, version: entry.version }); + } + return OBJECT_FREEZE({ surface: value.surface, sdk: value.sdk, base: OBJECT_FREEZE(base) }); +} + +function triggerShapes(value: unknown): readonly FlowExtensionTrigger[] { + if (!ARRAY_IS_ARRAY(value) || value.length > MAX_LIST) return invalid('triggers must be a list.'); + const output: FlowExtensionTrigger[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!record(entry) || !hasOnlyKeys(entry, ['provider', 'event', 'actions']) + || typeof entry.provider !== 'string' || !matches(PROVIDER, entry.provider) + || typeof entry.event !== 'string' || !matches(IDENTIFIER, entry.event)) { + return invalid('triggers entries are { provider, event, actions }.'); + } + for (let prior = 0; prior < output.length; prior += 1) { + if (output[prior]!.provider === entry.provider && output[prior]!.event === entry.event) { + return invalid(`Trigger ${entry.provider}:${entry.event} is declared twice.`); + } + } + output[output.length] = OBJECT_FREEZE({ + provider: entry.provider, + event: entry.event, + actions: stringList(entry.actions, `triggers ${entry.provider}.${entry.event} actions`, IDENTIFIER), + }); + } + return OBJECT_FREEZE(output); +} + +function permissionsShape(value: unknown): FlowExtensionPermissions { + if (!record(value) || !hasOnlyKeys(value, ['integrations', 'harnesses', 'mcp', 'writes', 'budget'])) { + return invalid('permissions expects integrations, harnesses, mcp, writes, and optional budget.'); + } + let budget: FlowExtensionPermissions['budget']; + if (value.budget !== undefined) { + if (!record(value.budget) || !hasOnlyKeys(value.budget, ['dollars', 'wallclock'])) { + return invalid('permissions.budget expects dollars and/or wallclock.'); + } + if (value.budget.dollars !== undefined + && (typeof value.budget.dollars !== 'number' || value.budget.dollars <= 0 + || !NUMBER_IS_FINITE(value.budget.dollars))) { + return invalid('permissions.budget.dollars must be a positive number.'); + } + if (value.budget.wallclock !== undefined + && (typeof value.budget.wallclock !== 'string' || !matches(WALLCLOCK, value.budget.wallclock))) { + return invalid('permissions.budget.wallclock must be a duration such as 45m.'); + } + const projected = OBJECT_CREATE(null) as { dollars?: number; wallclock?: string }; + if (value.budget.dollars !== undefined) projected.dollars = value.budget.dollars; + if (value.budget.wallclock !== undefined) projected.wallclock = value.budget.wallclock; + budget = OBJECT_FREEZE(projected); + } + const output = OBJECT_CREATE(null) as Record; + output.integrations = stringList(value.integrations, 'permissions.integrations', PROVIDER); + output.harnesses = stringList(value.harnesses, 'permissions.harnesses', /^[a-z]+$/); + output.mcp = stringList(value.mcp, 'permissions.mcp', /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/); + output.writes = stringList(value.writes, 'permissions.writes', WRITE_CLASS); + if (budget !== undefined) output.budget = budget; + return OBJECT_FREEZE(output) as unknown as FlowExtensionPermissions; +} + +function preflightShape(value: unknown): FlowExtensionManifest['preflight'] { + if (!record(value) || !hasOnlyKeys(value, ['credentials', 'servers'])) { + return invalid('Preflight requires credentials and servers arrays.'); + } + const servers = stringList(value.servers, 'preflight.servers', /^https?:\/\/\S+$/); + return OBJECT_FREEZE({ + credentials: stringList(value.credentials, 'preflight.credentials', IDENTIFIER), + servers, + }); +} + +function stringList(value: unknown, what: string, pattern: RegExp): readonly string[] { + if (!ARRAY_IS_ARRAY(value) || value.length > MAX_LIST) { + return invalid(`${what} must be a list of at most ${MAX_LIST} strings.`); + } + const output: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (typeof entry !== 'string' || !matches(pattern, entry)) return invalid(`${what} has an invalid entry.`); + for (let prior = 0; prior < output.length; prior += 1) { + if (output[prior] === entry) return invalid(`${what} lists ${entry} twice.`); + } + output[output.length] = entry; + } + return OBJECT_FREEZE(output); +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !ARRAY_IS_ARRAY(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const keys = OBJECT_KEYS(value); + for (let index = 0; index < keys.length; index += 1) { + let found = false; + for (let allowedIndex = 0; allowedIndex < allowed.length; allowedIndex += 1) { + if (keys[index] === allowed[allowedIndex]) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function matches(pattern: RegExp, value: string): boolean { + return REGEXP_TEST(pattern, value); +} + +function invalid(message: string): never { + throw new PluginError('plugin_manifest_invalid', message); +} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index f08a7e920..1aeb1d0c7 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,6 +1,7 @@ import { constants, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'; import { realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; +import { safePath } from './bundle.js'; import { canonicalize } from './canonical.js'; import { descriptorIsFile } from './fs-descriptor.js'; import { @@ -10,9 +11,10 @@ import { type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; import { findHostedProject } from './hosted-project.js'; +import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; -import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, parsePluginLock, type PluginLockEntry } from './plugin-lock.js'; -import { canonicalPluginRef, isGithubPluginRef, type PluginSourceRef } from './plugin-source.js'; +import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, type PluginLockEntry } from './plugin-lock.js'; +import { canonicalPluginRef, type PluginSourceRef } from './plugin-source.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; const INSTALLATION_AUTHORITY = new WeakSet(); @@ -39,8 +41,22 @@ const NUMBER = Number; const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; +const DATE_PARSE = Date.parse; +const NUMBER_IS_NAN = Number.isNaN; const OBJECT_FREEZE = Object.freeze; const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( map: WeakMap, key: K, @@ -58,6 +74,11 @@ const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as , value: T, ) => boolean; +const LOCK_HEX64 = /^[0-9a-f]{64}$/; +const LOCK_SHA = /^[0-9a-f]{40}$/; +const LOCK_OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const LOCK_REPO = /^[A-Za-z0-9_.-]{1,100}$/; +const HTTPS_GITHUB = /^https:\/\/github\.com\//; interface RuntimeGeneration { readonly origin: string; @@ -262,10 +283,10 @@ function hostedDeclaredExtensions( if (typeof ref !== 'string') { throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); } - if (isGithubPluginRef(ref)) declared[declared.length] = ref; + if (isHostedGithubPluginRef(ref)) declared[declared.length] = ref; } } - let lock: ReturnType = OBJECT_FREEZE({ + let lock: { readonly version: 2; readonly plugins: readonly PluginLockEntry[] } = OBJECT_FREEZE({ version: PLUGIN_LOCK_VERSION, plugins: OBJECT_FREEZE([]), }); @@ -278,7 +299,7 @@ function hostedDeclaredExtensions( if (error instanceof PluginError) throw error; throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`); } - lock = parsePluginLock(value); + lock = parseHostedPluginLock(value); } if (declared.length !== lock.plugins.length) { throw new PluginError('plugin_lock_invalid', 'Hosted flows.json and flows.lock.json declarations differ.'); @@ -300,6 +321,89 @@ function hostedDeclaredExtensions( return OBJECT_FREEZE(result); } +function parseHostedPluginLock(input: unknown): { + readonly version: 2; + readonly plugins: readonly PluginLockEntry[]; +} { + let value: unknown; + try { + value = snapshotJsonValue(input, 'hosted plugin lock'); + } catch { + return invalidHostedLock(`expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`); + } + if (!hostedRecord(value) || value.version !== PLUGIN_LOCK_VERSION || !ARRAY_IS_ARRAY(value.plugins) + || !hostedHasOnlyKeys(value, ['version', 'plugins'])) { + return invalidHostedLock(`expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`); + } + const names = new SET(); + const plugins: PluginLockEntry[] = []; + for (let index = 0; index < value.plugins.length; index += 1) { + const entry = value.plugins[index]; + if (!hostedRecord(entry) + || !hostedHasOnlyKeys(entry, ['digest', 'kind', 'manifestSha256', 'name', 'order', 'resolvedAt', 'source', 'version']) + || entry.kind !== 'flow-extension' || typeof entry.name !== 'string' || typeof entry.version !== 'string' + || typeof entry.digest !== 'string' || !REGEXP_TEST(LOCK_HEX64, entry.digest) + || typeof entry.manifestSha256 !== 'string' || !REGEXP_TEST(LOCK_HEX64, entry.manifestSha256) + || entry.order !== index + 1 || typeof entry.resolvedAt !== 'string' + || NUMBER_IS_NAN(DATE_PARSE(entry.resolvedAt)) || !hostedRecord(entry.source) + || !hostedHasOnlyKeys(entry.source, ['host', 'owner', 'repo', 'sha', 'path']) + || entry.source.host !== 'github' || typeof entry.source.owner !== 'string' + || !REGEXP_TEST(LOCK_OWNER, entry.source.owner) || typeof entry.source.repo !== 'string' + || !REGEXP_TEST(LOCK_REPO, entry.source.repo) || entry.source.repo === '.' || entry.source.repo === '..' + || typeof entry.source.sha !== 'string' || !REGEXP_TEST(LOCK_SHA, entry.source.sha) + || typeof entry.source.path !== 'string' + || (entry.source.path !== '' && !safePath(entry.source.path))) { + return invalidHostedLock(`plugins[${index}] is malformed.`); + } + if (SET_HAS(names, entry.name)) return invalidHostedLock(`plugin ${entry.name} is listed twice.`); + SET_ADD(names, entry.name); + plugins[plugins.length] = OBJECT_FREEZE({ + name: entry.name, + kind: 'flow-extension', + version: entry.version, + source: OBJECT_FREEZE({ + host: 'github', + owner: entry.source.owner, + repo: entry.source.repo, + sha: entry.source.sha, + path: entry.source.path, + }), + digest: entry.digest, + manifestSha256: entry.manifestSha256, + order: entry.order, + resolvedAt: entry.resolvedAt, + }); + } + return OBJECT_FREEZE({ version: PLUGIN_LOCK_VERSION, plugins: OBJECT_FREEZE(plugins) }); +} + +function isHostedGithubPluginRef(value: string): boolean { + return STRING_STARTS_WITH(value, 'github:') || REGEXP_TEST(HTTPS_GITHUB, value); +} + +function hostedRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !ARRAY_IS_ARRAY(value); +} + +function hostedHasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const keys = OBJECT_KEYS(value); + for (let index = 0; index < keys.length; index += 1) { + let found = false; + for (let allowedIndex = 0; allowedIndex < allowed.length; allowedIndex += 1) { + if (keys[index] === allowed[allowedIndex]) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function invalidHostedLock(message: string): never { + throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: ${message}`); +} + function readBoundedDeclaration(path: string): Buffer { let descriptor: number | undefined; try { diff --git a/packages/sdk/src/semver-range.ts b/packages/sdk/src/semver-range.ts index 3a2cfc8ed..97cc34776 100644 --- a/packages/sdk/src/semver-range.ts +++ b/packages/sdk/src/semver-range.ts @@ -9,19 +9,41 @@ */ const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; const RANGE = /^(\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/; +const MATH_MAX = Math.max; +const NUMBER = Number; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as ( + regexp: RegExp, + value: string, +) => RegExpExecArray | null; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as ( + value: string, + start: number, + end?: number, +) => string; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, + separator: string, +) => string[]; interface Parsed { readonly triple: readonly [number, number, number]; readonly pre?: string } export function parseVersion(value: string): Parsed | undefined { - const m = VERSION.exec(value); + const m = REGEXP_EXEC(VERSION, value); if (!m) return undefined; - const triple = [Number(m[1]), Number(m[2]), Number(m[3])] as const; - if (triple.some(n => !Number.isSafeInteger(n))) return undefined; + const triple = [NUMBER(m[1]), NUMBER(m[2]), NUMBER(m[3])] as const; + for (let index = 0; index < triple.length; index += 1) { + if (!NUMBER_IS_SAFE_INTEGER(triple[index])) return undefined; + } return m[4] === undefined ? { triple } : { triple, pre: m[4] }; } export function isVersionRange(value: string): boolean { - return RANGE.test(value); + return REGEXP_TEST(RANGE, value); } function compare(a: Parsed, b: Parsed): number { @@ -33,16 +55,16 @@ function compare(a: Parsed, b: Parsed): number { } function comparePrerelease(a: string, b: string): number { - const as = a.split('.'); - const bs = b.split('.'); - const n = Math.max(as.length, bs.length); + const as = STRING_SPLIT(a, '.'); + const bs = STRING_SPLIT(b, '.'); + const n = MATH_MAX(as.length, bs.length); for (let i = 0; i < n; i++) { const left = as[i]; const right = bs[i]; if (left === undefined) return -1; if (right === undefined) return 1; - const leftNum = /^\d+$/.test(left) ? Number(left) : undefined; - const rightNum = /^\d+$/.test(right) ? Number(right) : undefined; + const leftNum = REGEXP_TEST(/^\d+$/, left) ? NUMBER(left) : undefined; + const rightNum = REGEXP_TEST(/^\d+$/, right) ? NUMBER(right) : undefined; if (leftNum !== undefined && rightNum !== undefined) { if (leftNum !== rightNum) return leftNum - rightNum; continue; @@ -59,15 +81,18 @@ export function satisfiesRange(version: string, range: string): boolean { const v = parseVersion(version); if (v === undefined || !isVersionRange(range)) return false; if (range === '*') return true; - const [lowerText, upperText] = range.split(' ') as [string, string | undefined]; - const operator = /^[\^~]|^>=/.exec(lowerText)?.[0] ?? ''; - const lower = parseVersion(lowerText.slice(operator.length))!; + const parts = STRING_SPLIT(range, ' '); + const lowerText = parts[0]!; + const upperText = parts[1]; + const operator = REGEXP_EXEC(/^[\^~]|^>=/, lowerText)?.[0] ?? ''; + const lower = parseVersion(STRING_SLICE(lowerText, operator.length))!; if (compare(v, lower) < 0) return false; if (operator === '') return compare(v, lower) === 0; let upper: Parsed | undefined; - if (upperText !== undefined) upper = parseVersion(upperText.slice(1)); + if (upperText !== undefined) upper = parseVersion(STRING_SLICE(upperText, 1)); else if (operator === '^') { - const [major, minor] = lower.triple; + const major = lower.triple[0]; + const minor = lower.triple[1]; upper = major > 0 ? { triple: [major + 1, 0, 0] } : minor > 0 ? { triple: [0, minor + 1, 0] } : { triple: [0, 0, lower.triple[2] + 1] }; } else if (operator === '~') upper = { triple: [lower.triple[0], lower.triple[1] + 1, 0] }; return upper === undefined || compare(v, upper) < 0; diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 9acf24f44..b5eacf963 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -18,6 +18,7 @@ import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { loadHostedExtensionArtifacts, runVerifiedNativeExtensionSandbox, + selectHostedExtensionForRuntime, type HostedExtensionArtifact, } from '../src/hosted-extension-isolation.js'; import { runtimeVersions } from '../src/flow-extension-compat.js'; @@ -158,6 +159,58 @@ describe('hosted extension capability isolation', () => { expect(() => readFileSync(marker)).toThrow(); }); + it('parses hosted locks and manifests without ambient map or freeze methods', async () => { + const installed = await artifact(); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ plugins: [installed.ref] })); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + const map = Array.prototype.map; + const freeze = Object.freeze; + let poisonCalls = 0; + try { + Array.prototype.map = function poisonedMap(this: unknown[], ...args: unknown[]) { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-') || caller.includes('/src/plugin-lock.')) { + poisonCalls += 1; + throw new Error('ambient map must not run'); + } + return Reflect.apply(map, this, args as Parameters); + } as typeof Array.prototype.map; + Object.freeze = ((value: object) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-') || caller.includes('/src/flow-extension-manifest.')) { + poisonCalls += 1; + throw new Error('ambient freeze must not run'); + } + return freeze(value); + }) as typeof Object.freeze; + const installation = await loadHostedExtensionArtifacts(flowPath); + await expect(selectHostedExtensionForRuntime( + installation, + { name: 'software-factory', version: '2.0.22' }, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + runtimeVersions(), + )).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + } finally { + Array.prototype.map = map; + Object.freeze = freeze; + } + expect(poisonCalls).toBe(0); + }); + it.each(['queued', 'duplicate'] as const)('executes the exact capability-only handler for a %s receipt', async status => { const installed = await artifact(); const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ From e0bc480bfbe3b1edc2a71efeb86baac350a3fea2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:51:07 -0700 Subject: [PATCH 55/80] docs(evidence): record hosted parser gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 05f2654f8..a1a1ab992 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1425,3 +1425,56 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.tPG8GP/flows --help >/dev/null $ printf 'standalone-static-json-pass %s\n' /tmp/flows-pr552-standalone.tPG8GP/flows standalone-static-json-pass /tmp/flows-pr552-standalone.tPG8GP/flows + +$ git rev-parse HEAD +aa26c50eb7ae00c5916084e6c1075712ac90fce3 + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 55ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 647ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 587ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 101ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 585ms + ✓ tests/authored-flow.test.ts (34 tests) 782ms + ✓ tests/hosted-extension-isolation.test.ts (20 tests) 1052ms + + Test Files 9 passed (9) + Tests 215 passed (215) + Start at 15:50:08 + Duration 2.66s (transform 1.58s, setup 0ms, collect 8.33s, tests 3.71s, environment 2ms, prepare 524ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.UflORW/flows +Bundled 780 modules in 32ms +authored-node.mjs 3.1 MB (entry point) +[56ms] bundle 713 modules +[102ms] compile /tmp/flows-pr552-standalone.UflORW/flows + +$ /tmp/flows-pr552-standalone.UflORW/flows --help >/dev/null +$ printf 'standalone-hosted-parser-pass %s\n' /tmp/flows-pr552-standalone.UflORW/flows +standalone-hosted-parser-pass /tmp/flows-pr552-standalone.UflORW/flows From b94f913a1d4ecc0e88b885b6bdc274b8bc0a5d11 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:55:14 -0700 Subject: [PATCH 56/80] fix(sdk): capture hosted hash factory Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/bundle.ts | 7 +++--- .../tests/babysitter-native-extension.test.ts | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index 990c4d071..7eb49567b 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -4,11 +4,12 @@ import { basename, dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; type Hash = ReturnType; -const HASH_UPDATE = Function.prototype.call.bind(createHash('sha256').update) as ( +const CREATE_HASH = createHash; +const HASH_UPDATE = Function.prototype.call.bind(CREATE_HASH('sha256').update) as ( hash: Hash, data: Uint8Array | string, ) => Hash; -const HASH_DIGEST = Function.prototype.call.bind(createHash('sha256').digest) as ( +const HASH_DIGEST = Function.prototype.call.bind(CREATE_HASH('sha256').digest) as ( hash: Hash, encoding: 'hex', ) => string; @@ -34,7 +35,7 @@ export interface BundleOptions { } export function sha256(data: Uint8Array | string): string { - const hash = createHash('sha256'); + const hash = CREATE_HASH('sha256'); HASH_UPDATE(hash, data); return HASH_DIGEST(hash, 'hex'); } diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 2182721d4..b99a5d2b3 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { Ctx } from '@relayflows/surface'; @@ -287,6 +288,28 @@ describe('native Babysitter extension', () => { expect(poisonCalls).toBe(0); }); + it('checks the reviewed base pin with the captured hash factory', async () => { + const racing = await composed(); + writeFileSync(racing.flowPath, `export default {};\n`); + const require = createRequire(import.meta.url); + const crypto = require('node:crypto') as typeof import('node:crypto'); + const originalCreateHash = crypto.createHash; + let poisonCalls = 0; + try { + crypto.createHash = (() => { + poisonCalls += 1; + throw new Error('ambient createHash must not run'); + }) as typeof crypto.createHash; + syncBuiltinESMExports(); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + crypto.createHash = originalCreateHash; + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + it('refuses stale installation authority after the same flow path is redeployed', async () => { const configPath = join(installed.cwd, 'flows.json'); const lockPath = join(installed.cwd, 'flows.lock.json'); From 50b88c603d6bc2bf772f5a1621ed75285f478898 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 15:55:40 -0700 Subject: [PATCH 57/80] docs(evidence): record hash factory gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index a1a1ab992..fd166f444 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1478,3 +1478,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.UflORW/flows --help >/dev/null $ printf 'standalone-hosted-parser-pass %s\n' /tmp/flows-pr552-standalone.UflORW/flows standalone-hosted-parser-pass /tmp/flows-pr552-standalone.UflORW/flows + +$ git rev-parse HEAD +70295e43 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 216 passed (216) + Start at 15:54:51 + Duration 2.63s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.0rAzuk/flows +Bundled 780 modules in 30ms +authored-node.mjs 3.1 MB (entry point) +[50ms] bundle 713 modules +[88ms] compile /tmp/flows-pr552-standalone.0rAzuk/flows + +$ /tmp/flows-pr552-standalone.0rAzuk/flows --help >/dev/null +$ printf 'standalone-hash-factory-pass %s\n' /tmp/flows-pr552-standalone.0rAzuk/flows +standalone-hash-factory-pass /tmp/flows-pr552-standalone.0rAzuk/flows From 8a7cfbfb2e3f9c9fe7e854b8b6321edf8323eb37 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:13:17 -0700 Subject: [PATCH 58/80] fix(sdk): block inherited hosted thenables Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/hosted-base-snapshot.ts | 19 ++++++---- .../sdk/src/hosted-extension-isolation.ts | 9 ++++- packages/sdk/src/hosted-extension-manifest.ts | 3 +- packages/sdk/src/hosted-extension-protocol.ts | 7 +++- packages/sdk/src/hosted-extension-runtime.ts | 21 +++++++---- packages/sdk/src/hosted-extension-sandbox.ts | 2 ++ packages/sdk/src/hosted-promise-safety.ts | 36 +++++++++++++++++++ .../sdk/tests/hosted-base-snapshot.test.ts | 32 +++++++++++++++++ 8 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 packages/sdk/src/hosted-promise-safety.ts diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index f05b0aadf..b95397f89 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -17,6 +17,11 @@ import { statDescriptor, } from './fs-descriptor.js'; import { findHostedProject } from './hosted-project.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, + hostedPromiseValue, +} from './hosted-promise-safety.js'; import { PluginError } from './plugin-manifest.js'; const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); @@ -103,6 +108,7 @@ export async function createHostedBaseSnapshot( flowPath: string, projectRootOverride?: string, ): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); const origin = await REALPATH(PATH_RESOLVE(flowPath)); const discovered = projectRootOverride ?? findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin); const projectRoot = await REALPATH(discovered); @@ -115,7 +121,7 @@ export async function createHostedBaseSnapshot( ) { throw invalid('Hosted base flow must be a file inside its project root.'); } - const liveSources = OBJECT_FREEZE([OBJECT_FREEZE({ root: projectRoot, prefix: '' })]); + const liveSources = frozenHostedPromiseValue([OBJECT_FREEZE({ root: projectRoot, prefix: '' })]); const files = await readAuthorityFiles(liveSources); const liveDigest = sourceDigest(files); const liveFlow = sourceFileAt(files, flowRelative); @@ -138,7 +144,7 @@ export async function createHostedBaseSnapshot( if (snapshotFlow === undefined || snapshotFlow.sha256 !== liveFlow.sha256) { throw invalid('Hosted base private snapshot does not contain its buffered flow source.'); } - return OBJECT_FREEZE({ + return frozenHostedPromiseValue({ liveSources, liveDigest, snapshotRoot, @@ -155,6 +161,7 @@ export async function hostedBaseSourceDigest( sources: readonly HostedBaseSourceRoot[], hooks: HostedBaseSnapshotTestHooks = EMPTY_HOSTED_BASE_HOOKS, ): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); return sourceDigest(await readAuthorityFiles(sources, hooks)); } @@ -176,7 +183,7 @@ async function readAuthorityFiles( } } ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); - return OBJECT_FREEZE(files); + return frozenHostedPromiseValue(files); } async function readSnapshotTree(root: string): Promise { @@ -214,7 +221,7 @@ async function readSnapshotTree(root: string): Promise { } await visit(root, ''); ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); - return OBJECT_FREEZE(files); + return frozenHostedPromiseValue(files); } async function readTree( @@ -288,7 +295,7 @@ async function readTree( if (error instanceof PluginError) throw error; throw invalid('Hosted base source or trusted dependency is unreadable.'); } - return OBJECT_FREEZE(files); + return frozenHostedPromiseValue(files); } function ownHook( @@ -315,7 +322,7 @@ async function readBounded(descriptor: number, expectedBytes: number, relativePa if ((await readDescriptor(descriptor, extra, 0, 1, expectedBytes)) !== 0) { throw invalid(`Hosted base source changed while reading "${relativePath}".`); } - return bytes; + return hostedPromiseValue(bytes); } function tooLarge(): PluginError { diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index be01cea74..d69962a8d 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -15,6 +15,10 @@ import { type HostedExtensionInstallation, } from './hosted-extension-runtime.js'; import { PluginError } from './plugin-manifest.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; import { readStoredPluginFiles } from './plugin-store.js'; import { boundedJsonSnapshot, @@ -115,6 +119,7 @@ export type HostedExtensionResult = HostedExtensionProtocolResult; export async function runHostedCapabilityExtension( options: RunHostedExtensionOptions, ): Promise { + assertHostedPromiseSafety('plugin_unsupported'); const identity = hostedExtensionDispatchIdentity(options.dispatch); await assertHostedRuntimeAuthority(options.installation, options.base); const { artifact, manifest } = await selectHostedExtensionForRuntime( @@ -149,6 +154,7 @@ interface RunVerifiedNativeExtensionOptions { export async function runVerifiedNativeExtensionSandbox( options: RunVerifiedNativeExtensionOptions, ): Promise { + assertHostedPromiseSafety('plugin_unsupported'); assertCapabilityOnlyManifest(options.manifest); const identity = hostedExtensionDispatchIdentity(options.dispatch); assertManifestRoutes(options.manifest, identity); @@ -190,6 +196,7 @@ export async function selectHostedExtensionForRuntime( identity: { readonly provider: string; readonly event: string; readonly action?: string }, versions: Readonly<{ sdk: string; surface: string }>, ): Promise<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> { + assertHostedPromiseSafety('plugin_source_invalid'); assertHostedInstallationAuthority(value); if (typeof base !== 'object' || base === null || typeof base.name !== 'string' || (base.version !== undefined && typeof base.version !== 'string')) { @@ -215,7 +222,7 @@ export async function selectHostedExtensionForRuntime( } await assertPinnedBabysitter(selected.artifact); assertCapabilityOnlyManifest(selected.manifest); - return selected; + return frozenHostedPromiseValue(selected); } async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { diff --git a/packages/sdk/src/hosted-extension-manifest.ts b/packages/sdk/src/hosted-extension-manifest.ts index 0381527c7..c4b191072 100644 --- a/packages/sdk/src/hosted-extension-manifest.ts +++ b/packages/sdk/src/hosted-extension-manifest.ts @@ -6,6 +6,7 @@ import type { FlowExtensionTrigger, } from './flow-extension-manifest.js'; import { snapshotJsonValue } from './json-value.js'; +import { frozenHostedPromiseValue } from './hosted-promise-safety.js'; import { PluginError } from './plugin-manifest.js'; const ARRAY_IS_ARRAY = Array.isArray; @@ -95,7 +96,7 @@ export function validateHostedFlowExtensionManifest(input: unknown): FlowExtensi output.triggers = triggerShapes(value.triggers ?? []); output.permissions = permissionsShape(value.permissions); output.preflight = preflight; - return OBJECT_FREEZE(output) as unknown as FlowExtensionManifest; + return frozenHostedPromiseValue(output) as unknown as FlowExtensionManifest; } function extensionShape(value: unknown): FlowExtensionManifest['extends'] { diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index f926a676a..f8d1fc385 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -4,6 +4,10 @@ import { Readable, Writable } from 'node:stream'; import { clearTimeout as nodeClearTimeout, setTimeout as nodeSetTimeout } from 'node:timers'; import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const MAX_FRAME_BYTES = 256 * 1024; @@ -94,6 +98,7 @@ export async function exchangeHostedExtension( request: unknown, invoke: (request: unknown) => Promise, ): Promise { + assertHostedPromiseSafety('plugin_unsupported'); const requestSnapshot = boundedJsonSnapshot(request, 'hosted extension request'); let buffer = ''; let stderrText = ''; @@ -202,7 +207,7 @@ export async function exchangeHostedExtension( || message.completionReason !== 'success' || message.capabilityCalls !== 1) { return refuse('Hosted extension reported a completion without exactly one capability call.'); } - finish(undefined, OBJECT_FREEZE({ completionReason: 'success', capabilityCalls: 1 })); + finish(undefined, frozenHostedPromiseValue({ completionReason: 'success', capabilityCalls: 1 })); } else if (message.type === 'error') { if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { return refuse('Hosted extension emitted a malformed error frame.'); diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 1aeb1d0c7..7fab0d513 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -11,6 +11,10 @@ import { type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; import { findHostedProject } from './hosted-project.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, type PluginLockEntry } from './plugin-lock.js'; @@ -116,6 +120,7 @@ export interface HostedExtensionRuntime { /** Load one base/installation generation that cannot be paired across redeploys. */ export async function loadHostedExtensionRuntime(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); const loaded = await installationAt(origin, generation); @@ -129,11 +134,12 @@ export async function loadHostedExtensionRuntime(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); const loaded = await installationAt(origin, generation); @@ -143,6 +149,7 @@ export async function loadHostedExtensionArtifacts(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); const origin = await REALPATH(PATH_RESOLVE(flowPath)); const generation = newGeneration(origin); generation.declarations = declaredExtensions(generation).signature; @@ -201,10 +208,10 @@ async function installationAt( }> { const root = generation.projectRoot; if (root === undefined) - return { + return frozenHostedPromiseValue({ installation: installation([], generation), declarations: canonicalize([]), - }; + }); const artifacts: HostedExtensionArtifact[] = []; const declared = hostedDeclaredExtensions(root); for (let index = 0; index < declared.length; index += 1) { @@ -220,10 +227,10 @@ async function installationAt( manifestSha256: entry.manifestSha256, }); } - return { + return frozenHostedPromiseValue({ installation: installation(artifacts, generation), declarations: declarationSignature(declared), - }; + }); } function declaredExtensions(generation: RuntimeGeneration): { readonly signature: string } { @@ -478,7 +485,7 @@ async function baseAt(origin: string, generation: RuntimeGeneration): Promise { + assertHostedPromiseSafety('plugin_unsupported'); if (PROCESS_PLATFORM !== 'linux') return unsupported('hosted extension isolation requires Linux'); const nodeOverride = ownOption(options, 'nodePath'); if (nodeOverride === undefined && !supportsHostedSandboxFlags(PROCESS_NODE_VERSION)) { diff --git a/packages/sdk/src/hosted-promise-safety.ts b/packages/sdk/src/hosted-promise-safety.ts new file mode 100644 index 000000000..3f55827a1 --- /dev/null +++ b/packages/sdk/src/hosted-promise-safety.ts @@ -0,0 +1,36 @@ +import { PluginError, type PluginFailureKind } from './plugin-manifest.js'; + +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const OBJECT_PROTOTYPE = Object.prototype; + +/** + * Promise resolution reads a returned object's inherited `then` property. + * Authored code runs in this process before the hosted boundary, so refuse + * before the first await if it has made every ordinary object thenable. + */ +export function assertHostedPromiseSafety( + code: PluginFailureKind, + message = 'Hosted isolation refuses an ambient Object.prototype.then.', +): void { + if (OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then') !== undefined) { + throw new PluginError(code, message); + } +} + +/** Shadow inherited thenables before an object is used to settle a promise. */ +export function hostedPromiseValue(value: T): T { + OBJECT_DEFINE_PROPERTY(value, 'then', { + configurable: false, + enumerable: false, + value: undefined, + writable: false, + }); + return value; +} + +/** Freeze a behavior-free object after shadowing inherited thenables. */ +export function frozenHostedPromiseValue(value: T): Readonly { + return OBJECT_FREEZE(hostedPromiseValue(value)); +} diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 7c45286a5..1a3fec7ce 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -28,6 +28,38 @@ function fixture() { } describe('hosted base private snapshot', () => { + it('shadows inherited thenables on completed snapshot authority', async () => { + const { flowPath } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath); + try { + expect(Object.getOwnPropertyDescriptor(snapshot, 'then')).toMatchObject({ value: undefined }); + expect(Object.getOwnPropertyDescriptor(snapshot.liveSources, 'then')).toMatchObject({ value: undefined }); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + + it('refuses an inherited then hook before starting the snapshot boundary', async () => { + const { flowPath } = fixture(); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); + const hasOwn = Object.hasOwn; + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'then', { + configurable: true, + get(this: object) { + if (hasOwn(this, 'snapshotFlowSha256')) poisonCalls += 1; + return undefined; + }, + }); + await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + if (previous === undefined) delete (Object.prototype as { then?: unknown }).then; + else Object.defineProperty(Object.prototype, 'then', previous); + } + expect(poisonCalls).toBe(0); + }); + it('keeps the buffered base bytes when the live source changes', async () => { const { flowPath, source } = fixture(); const snapshot = await createHostedBaseSnapshot(flowPath); From 17ce74b2871b3131f03156d93037503245ba34b2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:13:44 -0700 Subject: [PATCH 59/80] docs(evidence): record promise safety gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index fd166f444..6357ba156 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1515,3 +1515,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.0rAzuk/flows --help >/dev/null $ printf 'standalone-hash-factory-pass %s\n' /tmp/flows-pr552-standalone.0rAzuk/flows standalone-hash-factory-pass /tmp/flows-pr552-standalone.0rAzuk/flows + +$ git rev-parse HEAD +a57bdb5f + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 218 passed (218) + Start at 16:12:53 + Duration 2.62s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.cEFYKP/flows +Bundled 780 modules in 31ms +authored-node.mjs 3.1 MB (entry point) +[49ms] bundle 713 modules +[91ms] compile /tmp/flows-pr552-standalone.cEFYKP/flows + +$ /tmp/flows-pr552-standalone.cEFYKP/flows --help >/dev/null +$ printf 'standalone-promise-safety-pass %s\n' /tmp/flows-pr552-standalone.cEFYKP/flows +standalone-promise-safety-pass /tmp/flows-pr552-standalone.cEFYKP/flows From 61ac374074d038131d2b402575e4e2d44866ff26 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:31:56 -0700 Subject: [PATCH 60/80] fix(sdk): preserve hosted intrinsic invariants Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/hosted-base-snapshot.ts | 3 +- packages/sdk/src/hosted-extension-protocol.ts | 8 ++- packages/sdk/src/hosted-extension-sandbox.ts | 10 ++-- packages/sdk/src/hosted-promise-safety.ts | 50 +++++++++++++++---- packages/sdk/src/plugin-store.ts | 3 +- .../sdk/tests/hosted-base-snapshot.test.ts | 45 +++++++++++++---- ...sted-extension-protocol-intrinsics.test.ts | 35 +++++++++++++ .../tests/hosted-extension-routing.test.ts | 21 ++++++++ .../sdk/tests/plugin-store-bounds.test.ts | 23 +++++++++ 9 files changed, 171 insertions(+), 27 deletions(-) diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index b95397f89..23d124a3f 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -40,6 +40,7 @@ const PATH_JOIN = join; const PATH_RELATIVE = relative; const PATH_RESOLVE = resolve; const PATH_SEPARATOR = sep; +const PROCESS_PLATFORM = process.platform; const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( array: T[], @@ -230,7 +231,7 @@ async function readTree( budget: SourceBudget, hooks: HostedBaseSnapshotTestHooks, ): Promise { - if (process.platform !== 'linux') { + if (PROCESS_PLATFORM !== 'linux') { throw invalid('Hosted base source snapshotting requires Linux.'); } const files: SourceFile[] = []; diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts index f8d1fc385..a468587d7 100644 --- a/packages/sdk/src/hosted-extension-protocol.ts +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -20,6 +20,10 @@ const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.k const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, ) => EventEmitter; +const ERROR_IS_INSTANCE = Function.prototype.call.bind( + Function.prototype[Symbol.hasInstance], + Error, +) as (value: unknown) => boolean; const JSON_PARSE = JSON.parse; const JSON_STRINGIFY = JSON.stringify; const OBJECT_CREATE = Object.create; @@ -124,8 +128,8 @@ export async function exchangeHostedExtension( if (error !== undefined) rejectPromise(error); else resolvePromise(result!); }; - const failure = (error: unknown): Error => error instanceof Error - ? error + const failure = (error: unknown): Error => ERROR_IS_INSTANCE(error) + ? error as Error : new PluginError('plugin_unsupported', 'Hosted capability rejected with a non-error value.'); const refuse = (message: string) => { const error = new PluginError('plugin_unsupported', message); diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 4d6a4da61..67aec1fda 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -42,7 +42,11 @@ const JSON_PARSE = JSON.parse; const OBJECT_ENTRIES = Object.entries; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const NUMBER = Number; const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as ( + regexp: RegExp, value: string, +) => RegExpExecArray | null; const SET = Set; const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; @@ -326,10 +330,10 @@ function ownOption(options: object, name: string): T | undefined { } export function supportsHostedSandboxFlags(version: string): boolean { - const match = /^(\d+)\.(\d+)\.(\d+)(?:-|$)/.exec(version); + const match = REGEXP_EXEC(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/, version); if (match === null) return false; - const major = Number(match[1]); - const minor = Number(match[2]); + const major = NUMBER(match[1]); + const minor = NUMBER(match[2]); return major >= 24 || (major === 23 && minor >= 5) || (major === 22 && minor >= 13); } diff --git a/packages/sdk/src/hosted-promise-safety.ts b/packages/sdk/src/hosted-promise-safety.ts index 3f55827a1..a6c6024ac 100644 --- a/packages/sdk/src/hosted-promise-safety.ts +++ b/packages/sdk/src/hosted-promise-safety.ts @@ -4,29 +4,61 @@ const OBJECT_DEFINE_PROPERTY = Object.defineProperty; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_PROTOTYPE = Object.prototype; +const INERT_THEN = { + configurable: false, + enumerable: false, + value: undefined, + writable: false, +} as const; +const PROTOTYPE_THEN_GET = () => undefined; +const PROTOTYPE_THEN_SET = function setOwnThen(this: object, value: unknown): void { + if (this === OBJECT_PROTOTYPE) return; + // Preserve ordinary `object.then = value` behavior for dependencies while + // keeping the inherited slot immutable. Assignment reaches this setter only + // when the receiver does not already have its own `then` property. + OBJECT_DEFINE_PROPERTY(this, 'then', { + configurable: true, + enumerable: true, + value, + writable: true, + }); +}; +const LOCKED_PROTOTYPE_THEN = { + configurable: false, + enumerable: false, + get: PROTOTYPE_THEN_GET, + set: PROTOTYPE_THEN_SET, +} as const; + +// Promise assimilation consults the prototype after every async operation, +// not just at the public entry point. Lock this one dangerous slot while the +// realm is pristine so authored code cannot install a thenable between awaits. +const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +if (initialThen === undefined) OBJECT_DEFINE_PROPERTY(OBJECT_PROTOTYPE, 'then', LOCKED_PROTOTYPE_THEN); +const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +const PROMISE_PROTOTYPE_SAFE = installedThen !== undefined + && installedThen.get === PROTOTYPE_THEN_GET + && installedThen.set === PROTOTYPE_THEN_SET + && installedThen.configurable === false + && installedThen.enumerable === false; /** * Promise resolution reads a returned object's inherited `then` property. - * Authored code runs in this process before the hosted boundary, so refuse - * before the first await if it has made every ordinary object thenable. + * Refuse if module initialization did not begin in a pristine realm where the + * inert prototype slot could be locked for the lifetime of the process. */ export function assertHostedPromiseSafety( code: PluginFailureKind, message = 'Hosted isolation refuses an ambient Object.prototype.then.', ): void { - if (OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then') !== undefined) { + if (!PROMISE_PROTOTYPE_SAFE) { throw new PluginError(code, message); } } /** Shadow inherited thenables before an object is used to settle a promise. */ export function hostedPromiseValue(value: T): T { - OBJECT_DEFINE_PROPERTY(value, 'then', { - configurable: false, - enumerable: false, - value: undefined, - writable: false, - }); + OBJECT_DEFINE_PROPERTY(value, 'then', INERT_THEN); return value; } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index b2d5fe8d8..b6849f6e5 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -37,6 +37,7 @@ const WRITE_FILE = writeFile; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_RESOLVE = resolve; +const PROCESS_PLATFORM = process.platform; const ERROR = Error; const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; const MAX_PLUGIN_STORE_ENTRIES = 10_000; @@ -186,7 +187,7 @@ async function regularFile( async function openStoredFile(root: string, path: string, hooks: StoredPluginReadTestHooks): Promise { const parts = STRING_SPLIT(path, '/'); - if (process.platform !== 'linux') { + if (PROCESS_PLATFORM !== 'linux') { for (let i = 1; i < parts.length; i++) { let parent = root; for (let index = 0; index < i; index += 1) parent = PATH_JOIN(parent, parts[index]!); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 1a3fec7ce..616b165ed 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -39,24 +39,24 @@ describe('hosted base private snapshot', () => { } }); - it('refuses an inherited then hook before starting the snapshot boundary', async () => { + it('locks the inherited then slot before authored code can schedule a replacement', async () => { const { flowPath } = fixture(); - const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); - const hasOwn = Object.hasOwn; let poisonCalls = 0; - try { + const locked = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); + expect(locked).toMatchObject({ configurable: false, enumerable: false }); + expect(typeof locked?.get).toBe('function'); + expect(typeof locked?.set).toBe('function'); + expect(() => { Object.defineProperty(Object.prototype, 'then', { configurable: true, - get(this: object) { - if (hasOwn(this, 'snapshotFlowSha256')) poisonCalls += 1; + get() { + poisonCalls += 1; return undefined; }, }); - await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ code: 'plugin_source_invalid' }); - } finally { - if (previous === undefined) delete (Object.prototype as { then?: unknown }).then; - else Object.defineProperty(Object.prototype, 'then', previous); - } + }).toThrow(TypeError); + const snapshot = await createHostedBaseSnapshot(flowPath); + await removeHostedBaseSnapshot(snapshot); expect(poisonCalls).toBe(0); }); @@ -93,6 +93,29 @@ describe('hosted base private snapshot', () => { expect(after).not.toBe(before); }); + it('uses the module-captured platform during source traversal', async () => { + const { project } = fixture(); + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + let poisonCalls = 0; + try { + Object.defineProperty(process, 'platform', { + configurable: descriptor.configurable, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-base-snapshot.')) { + poisonCalls += 1; + throw new Error('ambient process.platform must not run'); + } + return descriptor.value; + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + Object.defineProperty(process, 'platform', descriptor); + } + expect(poisonCalls).toBe(0); + }); + it('excludes project node_modules from the admitted generation', async () => { const { project, flowPath } = fixture(); const dependency = join(project, 'node_modules/local-identity'); diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts index a685c3100..8634c1717 100644 --- a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -56,6 +56,41 @@ it('constructs protocol completion with the captured Promise', async () => { expect(poisonCalls).toBe(0); }); +it('classifies adapter rejection with the captured Error brand', async () => { + const NativeError = Error; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const refusal = Object.assign(new NativeError('typed refusal'), { code: 'cloud_policy_refusal' }); + let poisonCalls = 0; + class PoisonedError extends NativeError { + static [Symbol.hasInstance](_value: unknown) { + poisonCalls += 1; + throw new NativeError('ambient Error brand must not run'); + } + } + let run!: ReturnType; + try { + globalThis.Error = PoisonedError as ErrorConstructor; + run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { throw refusal; }, + ); + protocol.write(`${JSON.stringify({ + type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: 'exact' }, + })}\n`); + await expect(run).rejects.toBe(refusal); + } finally { + globalThis.Error = NativeError; + } + expect(poisonCalls).toBe(0); +}); + it('enforces the protocol deadline with captured timer operations', async () => { const nativeSetTimeout = globalThis.setTimeout; const nativeClearTimeout = globalThis.clearTimeout; diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index 29505272a..d9a5bfd2b 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -18,6 +18,27 @@ describe('hosted extension routing policy', () => { expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); }); + it('parses the captured Node version without ambient regex or number hooks', () => { + const exec = RegExp.prototype.exec; + const number = Number; + let poisonCalls = 0; + try { + RegExp.prototype.exec = (() => { + poisonCalls += 1; + throw new Error('ambient RegExp.exec must not run'); + }) as typeof RegExp.prototype.exec; + globalThis.Number = (() => { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + }) as unknown as NumberConstructor; + expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); + } finally { + RegExp.prototype.exec = exec; + globalThis.Number = number; + } + expect(poisonCalls).toBe(0); + }); + it('constructs the pinned Surface mounts without ambient array methods', () => { const surfaceFiles = [ 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 15dc875db..27b2ed50d 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -131,6 +131,29 @@ describe('bounded plugin-store verification', () => { expect(poisonCalls).toBe(0); }); + it('uses the module-captured platform while opening stored files', async () => { + const stored = await fixture(); + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + let poisonCalls = 0; + try { + Object.defineProperty(process, 'platform', { + configurable: descriptor.configurable, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient process.platform must not run'); + } + return descriptor.value; + }, + }); + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + Object.defineProperty(process, 'platform', descriptor); + } + expect(poisonCalls).toBe(0); + }); + it('ignores inherited store test hooks when production omits them', async () => { const stored = await fixture(); const names = ['beforeOpen', 'afterStat', 'beforeDirectoryStat'] as const; From 0c368af2cbd67177c939e8476cb01a6a87eb3a3a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:32:41 -0700 Subject: [PATCH 61/80] docs(evidence): record hosted invariant gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 6357ba156..231377af5 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1552,3 +1552,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.cEFYKP/flows --help >/dev/null $ printf 'standalone-promise-safety-pass %s\n' /tmp/flows-pr552-standalone.cEFYKP/flows standalone-promise-safety-pass /tmp/flows-pr552-standalone.cEFYKP/flows + +$ git rev-parse HEAD +6382e7fb + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 222 passed (222) + Start at 16:31:00 + Duration 2.48s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.s5kEqR/flows +Bundled 780 modules in 31ms +authored-node.mjs 3.1 MB (entry point) +[54ms] bundle 713 modules +[95ms] compile /tmp/flows-pr552-standalone.s5kEqR/flows + +$ /tmp/flows-pr552-standalone.s5kEqR/flows --help >/dev/null +$ printf 'standalone-hosted-invariants-pass %s\n' /tmp/flows-pr552-standalone.s5kEqR/flows +standalone-hosted-invariants-pass /tmp/flows-pr552-standalone.s5kEqR/flows From 9b25039883c50cd8ec836b770c0710232404ef3d Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 01:53:12 +0200 Subject: [PATCH 62/80] fix(sdk): shield native fs promise values Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/fs-descriptor.ts | 108 ++++++++++++++---- packages/sdk/src/hosted-base-snapshot.ts | 10 +- packages/sdk/src/plugin-store.ts | 23 ++-- packages/sdk/tests/fs-descriptor.test.ts | 77 +++++++++++++ .../sdk/tests/hosted-base-snapshot.test.ts | 29 +++++ .../sdk/tests/plugin-store-bounds.test.ts | 29 +++++ 6 files changed, 238 insertions(+), 38 deletions(-) create mode 100644 packages/sdk/tests/fs-descriptor.test.ts diff --git a/packages/sdk/src/fs-descriptor.ts b/packages/sdk/src/fs-descriptor.ts index f8fbdd28e..72ef02937 100644 --- a/packages/sdk/src/fs-descriptor.ts +++ b/packages/sdk/src/fs-descriptor.ts @@ -4,25 +4,24 @@ import { Stats, close, fstat, + lstat, open, + opendir, read, + readdir, type BigIntStats, type PathLike, } from 'node:fs'; -import { promisify } from 'node:util'; - -const CLOSE = promisify(close) as (descriptor: number) => Promise; -const FSTAT = promisify(fstat) as ( - descriptor: number, options: { bigint: true }, -) => Promise; -const OPEN = promisify(open) as (path: PathLike, flags: number) => Promise; -const READ = promisify(read) as ( - descriptor: number, - buffer: Buffer, - offset: number, - length: number, - position: number, -) => Promise<{ bytesRead: number; buffer: Buffer }>; +import { hostedPromiseValue } from './hosted-promise-safety.js'; + +const CLOSE = close; +const FSTAT = fstat; +const LSTAT = lstat; +const OPEN = open; +const OPENDIR = opendir; +const READ = read; +const READDIR = readdir; +const PROMISE = Promise; const DIRENT_IS_DIRECTORY = Function.prototype.call.bind(Dirent.prototype.isDirectory) as ( entry: Dirent, ) => boolean; @@ -31,10 +30,12 @@ const DIRENT_IS_FILE = Function.prototype.call.bind(Dirent.prototype.isFile) as ) => boolean; const DIR_CLOSE = Function.prototype.call.bind(Dir.prototype.close) as ( directory: Dir, -) => Promise; + callback: (error?: NodeJS.ErrnoException | null) => void, +) => void; const DIR_READ = Function.prototype.call.bind(Dir.prototype.read) as ( directory: Dir, -) => Promise; + callback: (error: NodeJS.ErrnoException | null, entry: Dirent | null) => void, +) => void; const STATS_IS_DIRECTORY = Function.prototype.call.bind(Stats.prototype.isDirectory) as ( stats: Stats | BigIntStats, ) => boolean; @@ -42,15 +43,73 @@ const STATS_IS_FILE = Function.prototype.call.bind(Stats.prototype.isFile) as ( stats: Stats | BigIntStats, ) => boolean; -export const closeDescriptor = CLOSE; export const descriptorIsDirectory = STATS_IS_DIRECTORY; export const descriptorIsFile = STATS_IS_FILE; export const directoryEntryIsDirectory = DIRENT_IS_DIRECTORY; export const directoryEntryIsFile = DIRENT_IS_FILE; -export const openDescriptor = OPEN; -export const readDirectoryEntry = DIR_READ; -export const closeDirectory = DIR_CLOSE; -export const statDescriptor = FSTAT; + +export function closeDescriptor(descriptor: number): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + CLOSE(descriptor, error => error === null ? resolvePromise() : rejectPromise(error)); + }); +} + +export function openDescriptor(path: PathLike, flags: number): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + OPEN(path, flags, (error, descriptor) => error === null ? resolvePromise(descriptor) : rejectPromise(error)); + }); +} + +export function statDescriptor(descriptor: number, options: { bigint: true }): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + FSTAT(descriptor, options, (error, stats) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(stats)); + }); + }); +} + +export function lstatPath(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + LSTAT(path, (error, stats) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(stats)); + }); + }); +} + +export function openDirectory(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + OPENDIR(path, (error, directory) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(directory)); + }); + }); +} + +export function readDirectory(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + READDIR(path, { withFileTypes: true }, (error, entries) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(entries)); + }); + }); +} + +export function readDirectoryEntry(directory: Dir): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + DIR_READ(directory, (error, entry) => { + if (error !== null) rejectPromise(error); + else resolvePromise(entry === null ? null : hostedPromiseValue(entry)); + }); + }); +} + +export function closeDirectory(directory: Dir): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + DIR_CLOSE(directory, error => error == null ? resolvePromise() : rejectPromise(error)); + }); +} export async function readDescriptor( descriptor: number, @@ -59,5 +118,10 @@ export async function readDescriptor( length: number, position: number, ): Promise { - return (await READ(descriptor, buffer, offset, length, position)).bytesRead; + return await new PROMISE((resolvePromise, rejectPromise) => { + READ(descriptor, buffer, offset, length, position, (error, bytesRead) => { + if (error !== null) rejectPromise(error); + else resolvePromise(bytesRead); + }); + }); } diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 23d124a3f..6b728913f 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { chmod, mkdir, mkdtemp, opendir, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { canonicalize } from './canonical.js'; @@ -11,8 +11,10 @@ import { descriptorIsFile, directoryEntryIsDirectory, directoryEntryIsFile, + openDirectory, openDescriptor, readDescriptor, + readDirectory, readDirectoryEntry, statDescriptor, } from './fs-descriptor.js'; @@ -28,8 +30,6 @@ const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); const CHMOD = chmod; const MKDIR = mkdir; const MKDTEMP = mkdtemp; -const OPENDIR = opendir; -const READDIR = readdir; const REALPATH = realpath; const RM = rm; const WRITE_FILE = writeFile; @@ -191,7 +191,7 @@ async function readSnapshotTree(root: string): Promise { const files: SourceFile[] = []; const budget: SourceBudget = { entries: 0, bytes: 0 }; async function visit(directory: string, prefix: string): Promise { - const entries = await READDIR(directory, { withFileTypes: true }); + const entries = await readDirectory(directory); ARRAY_SORT(entries, (left, right) => STRING_LOCALE_COMPARE(left.name, right.name)); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; @@ -237,7 +237,7 @@ async function readTree( const files: SourceFile[] = []; async function visit(directory: number, relativeDirectory: string, depth: number): Promise { if (depth > MAX_DEPTH) throw tooLarge(); - const entries = await OPENDIR(`/proc/self/fd/${directory}`); + const entries = await openDirectory(`/proc/self/fd/${directory}`); try { for (;;) { const entry = await readDirectoryEntry(entries); diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index b6849f6e5..38c76bb3e 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { lstat, mkdir, mkdtemp, opendir, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { payloadManifest, safePath, sha256 } from './bundle.js'; import { @@ -9,11 +9,14 @@ import { descriptorIsFile, directoryEntryIsDirectory, directoryEntryIsFile, + lstatPath, + openDirectory, openDescriptor, readDescriptor, readDirectoryEntry, statDescriptor, } from './fs-descriptor.js'; +import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; @@ -27,10 +30,8 @@ import { PluginError } from './plugin-manifest.js'; * refusal, not a surprise. */ export const PLUGIN_STORE = '.flows/plugins'; -const LSTAT = lstat; const MKDIR = mkdir; const MKDTEMP = mkdtemp; -const OPENDIR = opendir; const RENAME = rename; const RM = rm; const WRITE_FILE = writeFile; @@ -103,14 +104,14 @@ export async function materializePlugin( const directory = pluginStoreDirectory(root, name, digest); let exists = false; try { - await LSTAT(directory); + await lstatPath(directory); exists = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } if (exists) { await verifyStoredPlugin(directory, digest); - return { directory, digest }; + return frozenHostedPromiseValue({ directory, digest }); } const parent = PATH_DIRNAME(directory); await MKDIR(parent, { recursive: true }); @@ -138,7 +139,7 @@ export async function materializePlugin( } finally { await RM(staging, { recursive: true, force: true }); } - return { directory, digest }; + return frozenHostedPromiseValue({ directory, digest }); } async function regularFile( @@ -179,7 +180,7 @@ async function regularFile( if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); } - return bytes; + return hostedPromiseValue(bytes); } finally { await closeDescriptor(handle); } @@ -191,7 +192,7 @@ async function openStoredFile(root: string, path: string, hooks: StoredPluginRea for (let i = 1; i < parts.length; i++) { let parent = root; for (let index = 0; index < i; index += 1) parent = PATH_JOIN(parent, parts[index]!); - if (!descriptorIsDirectory(await LSTAT(parent))) { + if (!descriptorIsDirectory(await lstatPath(parent))) { throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); } } @@ -245,7 +246,7 @@ async function readVerifiedStoredPluginFiles( }; let manifest: Buffer; try { - if (!descriptorIsDirectory(await LSTAT(directory))) return drift('not a directory'); + if (!descriptorIsDirectory(await lstatPath(directory))) return drift('not a directory'); manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); @@ -294,7 +295,7 @@ async function readVerifiedStoredPluginFiles( for (let index = 0; index < files.length; index += 1) { frozen[index] = OBJECT_FREEZE(files[index]!); } - return OBJECT_FREEZE(frozen); + return frozenHostedPromiseValue(frozen); } /** Re-verify, then return every stored file including the payload `manifest.json`. */ @@ -329,7 +330,7 @@ async function rejectExtras( ): Promise { let entries = 0; async function visit(currentPrefix: string): Promise { - const directory = await OPENDIR(PATH_JOIN(root, currentPrefix)); + const directory = await openDirectory(PATH_JOIN(root, currentPrefix)); try { for (;;) { const entry = await readDirectoryEntry(directory); diff --git a/packages/sdk/tests/fs-descriptor.test.ts b/packages/sdk/tests/fs-descriptor.test.ts new file mode 100644 index 000000000..09b20560c --- /dev/null +++ b/packages/sdk/tests/fs-descriptor.test.ts @@ -0,0 +1,77 @@ +import { Dir, Dirent, Stats, closeSync, fstatSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + closeDescriptor, + closeDirectory, + lstatPath, + openDirectory, + openDescriptor, + readDirectory, + readDirectoryEntry, + statDescriptor, +} from '../src/fs-descriptor.js'; + +const roots: string[] = []; + +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +function fixture(): { directory: string; file: string } { + const directory = mkdtempSync(join(tmpdir(), 'fs-descriptor-test-')); + roots.push(directory); + const file = join(directory, 'entry.txt'); + writeFileSync(file, 'entry'); + return { directory, file }; +} + +function restoreThen(prototype: object, previous: PropertyDescriptor | undefined): void { + if (previous === undefined) delete (prototype as { then?: unknown }).then; + else Object.defineProperty(prototype, 'then', previous); +} + +describe('captured descriptor operations', () => { + it('shadows native fs values before promise resolution reads their prototypes', async () => { + const { directory, file } = fixture(); + const sample = openSync(file, 'r'); + const bigintStatsPrototype = Object.getPrototypeOf(fstatSync(sample, { bigint: true })) as object; + closeSync(sample); + const prototypes = [Array.prototype, Stats.prototype, bigintStatsPrototype, Dir.prototype, Dirent.prototype]; + const previous = prototypes.map(prototype => Object.getOwnPropertyDescriptor(prototype, 'then')); + let poisonCalls = 0; + try { + for (const prototype of prototypes) { + Object.defineProperty(prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + } + + const entries = await readDirectory(directory); + expect(entries.map(entry => entry.name)).toEqual(['entry.txt']); + expect(await lstatPath(file)).toBeInstanceOf(Stats); + + const descriptor = await openDescriptor(file, 0); + try { + expect((await statDescriptor(descriptor, { bigint: true })).size).toBe(5n); + } finally { + await closeDescriptor(descriptor); + } + + const opened = await openDirectory(directory); + try { + expect((await readDirectoryEntry(opened))?.name).toBe('entry.txt'); + } finally { + await closeDirectory(opened); + } + } finally { + for (let index = 0; index < prototypes.length; index += 1) { + restoreThen(prototypes[index]!, previous[index]); + } + } + expect(poisonCalls).toBe(0); + }); +}); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 616b165ed..db9c6c597 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -116,6 +116,35 @@ describe('hosted base private snapshot', () => { expect(poisonCalls).toBe(0); }); + it('shadows native directory arrays before promise resolution can substitute them', async () => { + const { flowPath } = fixture(); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'then'); + const empty: unknown[] = []; + Object.defineProperty(empty, 'then', { value: undefined }); + let poisonCalls = 0; + let snapshot: Awaited> | undefined; + try { + Object.defineProperty(Array.prototype, 'then', { + configurable: true, + get(this: unknown[]) { + const first = this[0] as { name?: unknown; isFile?: unknown } | undefined; + if (typeof first?.name === 'string' && typeof first.isFile === 'function') { + poisonCalls += 1; + return (resolvePromise: (value: unknown) => void) => resolvePromise(empty); + } + return undefined; + }, + }); + snapshot = await createHostedBaseSnapshot(flowPath); + expect(readFileSync(snapshot.snapshotFlowPath, 'utf8')).toContain('tenant base must not execute'); + } finally { + if (snapshot !== undefined) await removeHostedBaseSnapshot(snapshot); + if (previous === undefined) delete (Array.prototype as { then?: unknown }).then; + else Object.defineProperty(Array.prototype, 'then', previous); + } + expect(poisonCalls).toBe(0); + }); + it('excludes project node_modules from the admitted generation', async () => { const { project, flowPath } = fixture(); const dependency = join(project, 'node_modules/local-identity'); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 27b2ed50d..7b2b9e0cc 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -131,6 +131,35 @@ describe('bounded plugin-store verification', () => { expect(poisonCalls).toBe(0); }); + it('shadows stored buffers and arrays before async return assimilation', async () => { + const stored = await fixture(); + const prototypes = [Buffer.prototype, Array.prototype]; + const previous = prototypes.map(prototype => Object.getOwnPropertyDescriptor(prototype, 'then')); + let poisonCalls = 0; + try { + for (const prototype of prototypes) { + Object.defineProperty(prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + } + const files = await readStoredPluginFiles(stored.directory, stored.digest); + expect(files.map(file => file.path)).toEqual([ + 'manifest.json', 'entry.ts', 'flows-plugin.json', 'nested/value.ts', + ]); + } finally { + for (let index = 0; index < prototypes.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (prototypes[index]! as { then?: unknown }).then; + else Object.defineProperty(prototypes[index]!, 'then', descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + it('uses the module-captured platform while opening stored files', async () => { const stored = await fixture(); const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; From 11b8cde09648b44d374cdbcce66c9dc4d5bc5a95 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:55:45 -0700 Subject: [PATCH 63/80] fix(sdk): seal hosted promise result prototypes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../sdk/src/hosted-extension-declarations.ts | 386 ++++++++++++++++++ packages/sdk/src/hosted-extension-runtime.ts | 253 +----------- packages/sdk/src/hosted-promise-safety.ts | 65 ++- packages/sdk/src/plugin-store.ts | 2 +- .../sdk/tests/hosted-base-snapshot.test.ts | 15 +- .../sdk/tests/plugin-store-bounds.test.ts | 6 + 6 files changed, 457 insertions(+), 270 deletions(-) create mode 100644 packages/sdk/src/hosted-extension-declarations.ts diff --git a/packages/sdk/src/hosted-extension-declarations.ts b/packages/sdk/src/hosted-extension-declarations.ts new file mode 100644 index 000000000..be6c5d526 --- /dev/null +++ b/packages/sdk/src/hosted-extension-declarations.ts @@ -0,0 +1,386 @@ +import { + constants, + closeSync, + existsSync, + fstatSync, + openSync, + readSync, +} from "node:fs"; +import { join } from "node:path"; +import { safePath } from "./bundle.js"; +import { canonicalize } from "./canonical.js"; +import { descriptorIsFile } from "./fs-descriptor.js"; +import { snapshotJsonValue } from "./json-value.js"; +import { PluginError } from "./plugin-manifest.js"; +import { + PLUGIN_LOCK_FILE, + PLUGIN_LOCK_VERSION, + type PluginLockEntry, +} from "./plugin-lock.js"; +import { canonicalPluginRef, type PluginSourceRef } from "./plugin-source.js"; + +const CLOSE_SYNC = closeSync; +const EXISTS_SYNC = existsSync; +const FSTAT_SYNC = fstatSync; +const OPEN_SYNC = openSync; +const READ_SYNC = readSync; +const PATH_JOIN = join; +const ERROR = Error; +const PLUGIN_ERROR_IS_INSTANCE = Function.prototype.call.bind( + Function.prototype[Symbol.hasInstance], + PluginError, +) as (value: unknown) => boolean; +const MAX_DECLARATION_BYTES = 1024 * 1024; +const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind( + Buffer.prototype.toString, +) as (value: Buffer, encoding: BufferEncoding) => string; +const NUMBER = Number; +const DECLARATION_READ_FLAGS = + constants.O_RDONLY | + (constants.O_NOFOLLOW ?? 0) | + (constants.O_NONBLOCK ?? 0); +const JSON_PARSE = JSON.parse; +const ARRAY_IS_ARRAY = Array.isArray; +const DATE_PARSE = Date.parse; +const NUMBER_IS_NAN = Number.isNaN; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as ( + set: Set, + value: T, +) => Set; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as ( + set: Set, + value: T, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind( + String.prototype.startsWith, +) as (value: string, search: string) => boolean; +const LOCK_HEX64 = /^[0-9a-f]{64}$/; +const LOCK_SHA = /^[0-9a-f]{40}$/; +const LOCK_OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const LOCK_REPO = /^[A-Za-z0-9_.-]{1,100}$/; +const HTTPS_GITHUB = /^https:\/\/github\.com\//; + +export interface HostedDeclaredExtension { + readonly ref: string; + readonly entry: PluginLockEntry; + readonly source: PluginSourceRef; +} + +export function declarationSignature( + declared: readonly HostedDeclaredExtension[], +): string { + const records: Array<{ + ref: string; + name: string; + version: string; + digest: string; + manifestSha256: string; + }> = []; + for (let index = 0; index < declared.length; index += 1) { + const { ref, entry } = declared[index]!; + records[index] = { + ref, + name: entry.name, + version: entry.version, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + }; + } + return canonicalize(records); +} + +export function hostedDeclaredExtensions( + root: string, +): readonly HostedDeclaredExtension[] { + if ( + EXISTS_SYNC(PATH_JOIN(root, "flows.json.tmp")) || + EXISTS_SYNC(PATH_JOIN(root, `${PLUGIN_LOCK_FILE}.tmp`)) + ) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted extension declarations have a pending transaction.", + ); + } + let config: unknown; + try { + config = JSON_PARSE( + BUFFER_TO_STRING( + readBoundedDeclaration(PATH_JOIN(root, "flows.json")), + "utf8", + ), + ); + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_manifest_invalid", + "Invalid or oversized flows.json.", + ); + } + if (typeof config !== "object" || config === null || ARRAY_IS_ARRAY(config)) { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + const plugins = OBJECT_HAS_OWN(config, "plugins") + ? (config as { plugins?: unknown }).plugins + : undefined; + if (plugins !== undefined && !ARRAY_IS_ARRAY(plugins)) { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + const declared: string[] = []; + if (plugins !== undefined) { + for (let index = 0; index < plugins.length; index += 1) { + const ref = plugins[index]; + if (typeof ref !== "string") { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + if (isHostedGithubPluginRef(ref)) declared[declared.length] = ref; + } + } + let lock: { + readonly version: 2; + readonly plugins: readonly PluginLockEntry[]; + } = OBJECT_FREEZE({ + version: PLUGIN_LOCK_VERSION, + plugins: OBJECT_FREEZE([]), + }); + const lockPath = PATH_JOIN(root, PLUGIN_LOCK_FILE); + if (EXISTS_SYNC(lockPath)) { + let value: unknown; + try { + value = JSON_PARSE( + BUFFER_TO_STRING(readBoundedDeclaration(lockPath), "utf8"), + ); + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_lock_invalid", + `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`, + ); + } + lock = parseHostedPluginLock(value); + } + if (declared.length !== lock.plugins.length) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted flows.json and flows.lock.json declarations differ.", + ); + } + const result: HostedDeclaredExtension[] = []; + for (let index = 0; index < lock.plugins.length; index += 1) { + const entry = lock.plugins[index]!; + const source = OBJECT_FREEZE({ ...entry.source, ref: entry.source.sha }); + const ref = canonicalPluginRef(source); + if (declared[index] !== ref) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted flows.lock.json order differs from flows.json.plugins.", + ); + } + result[result.length] = OBJECT_FREEZE({ ref, entry, source }); + } + return OBJECT_FREEZE(result); +} + +function parseHostedPluginLock(input: unknown): { + readonly version: 2; + readonly plugins: readonly PluginLockEntry[]; +} { + let value: unknown; + try { + value = snapshotJsonValue(input, "hosted plugin lock"); + } catch { + return invalidHostedLock( + `expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`, + ); + } + if ( + !hostedRecord(value) || + value.version !== PLUGIN_LOCK_VERSION || + !ARRAY_IS_ARRAY(value.plugins) || + !hostedHasOnlyKeys(value, ["version", "plugins"]) + ) { + return invalidHostedLock( + `expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`, + ); + } + const names = new SET(); + const plugins: PluginLockEntry[] = []; + for (let index = 0; index < value.plugins.length; index += 1) { + const entry = value.plugins[index]; + if ( + !hostedRecord(entry) || + !hostedHasOnlyKeys(entry, [ + "digest", + "kind", + "manifestSha256", + "name", + "order", + "resolvedAt", + "source", + "version", + ]) || + entry.kind !== "flow-extension" || + typeof entry.name !== "string" || + typeof entry.version !== "string" || + typeof entry.digest !== "string" || + !REGEXP_TEST(LOCK_HEX64, entry.digest) || + typeof entry.manifestSha256 !== "string" || + !REGEXP_TEST(LOCK_HEX64, entry.manifestSha256) || + entry.order !== index + 1 || + typeof entry.resolvedAt !== "string" || + NUMBER_IS_NAN(DATE_PARSE(entry.resolvedAt)) || + !hostedRecord(entry.source) || + !hostedHasOnlyKeys(entry.source, [ + "host", + "owner", + "repo", + "sha", + "path", + ]) || + entry.source.host !== "github" || + typeof entry.source.owner !== "string" || + !REGEXP_TEST(LOCK_OWNER, entry.source.owner) || + typeof entry.source.repo !== "string" || + !REGEXP_TEST(LOCK_REPO, entry.source.repo) || + entry.source.repo === "." || + entry.source.repo === ".." || + typeof entry.source.sha !== "string" || + !REGEXP_TEST(LOCK_SHA, entry.source.sha) || + typeof entry.source.path !== "string" || + (entry.source.path !== "" && !safePath(entry.source.path)) + ) { + return invalidHostedLock(`plugins[${index}] is malformed.`); + } + if (SET_HAS(names, entry.name)) + return invalidHostedLock(`plugin ${entry.name} is listed twice.`); + SET_ADD(names, entry.name); + plugins[plugins.length] = OBJECT_FREEZE({ + name: entry.name, + kind: "flow-extension", + version: entry.version, + source: OBJECT_FREEZE({ + host: "github", + owner: entry.source.owner, + repo: entry.source.repo, + sha: entry.source.sha, + path: entry.source.path, + }), + digest: entry.digest, + manifestSha256: entry.manifestSha256, + order: entry.order, + resolvedAt: entry.resolvedAt, + }); + } + return OBJECT_FREEZE({ + version: PLUGIN_LOCK_VERSION, + plugins: OBJECT_FREEZE(plugins), + }); +} + +function isHostedGithubPluginRef(value: string): boolean { + return ( + STRING_STARTS_WITH(value, "github:") || REGEXP_TEST(HTTPS_GITHUB, value) + ); +} + +function hostedRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !ARRAY_IS_ARRAY(value); +} + +function hostedHasOnlyKeys( + value: Record, + allowed: readonly string[], +): boolean { + const keys = OBJECT_KEYS(value); + for (let index = 0; index < keys.length; index += 1) { + let found = false; + for ( + let allowedIndex = 0; + allowedIndex < allowed.length; + allowedIndex += 1 + ) { + if (keys[index] === allowed[allowedIndex]) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function invalidHostedLock(message: string): never { + throw new PluginError( + "plugin_lock_invalid", + `${PLUGIN_LOCK_FILE}: ${message}`, + ); +} + +function readBoundedDeclaration(path: string): Buffer { + let descriptor: number | undefined; + try { + descriptor = OPEN_SYNC(path, DECLARATION_READ_FLAGS); + const before = FSTAT_SYNC(descriptor, { bigint: true }); + if ( + !descriptorIsFile(before) || + before.size < 0n || + before.size > BIG_INT(MAX_DECLARATION_BYTES) + ) { + throw new PluginError( + "plugin_source_invalid", + "Hosted extension declaration is not a bounded regular file.", + ); + } + const expected = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(expected); + let offset = 0; + while (offset < expected) { + const count = READ_SYNC( + descriptor, + bytes, + offset, + expected - offset, + offset, + ); + if (count === 0) throw new ERROR("short read"); + offset += count; + } + if (READ_SYNC(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) + throw new ERROR("grew"); + const after = FSTAT_SYNC(descriptor, { bigint: true }); + if ( + after.size !== before.size || + after.mtimeNs !== before.mtimeNs || + after.ctimeNs !== before.ctimeNs + ) { + throw new ERROR("changed"); + } + return bytes; + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_source_invalid", + "Hosted extension declaration is unreadable or changed while reading.", + ); + } finally { + if (descriptor !== undefined) CLOSE_SYNC(descriptor); + } +} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 7fab0d513..e251f4e6d 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -1,66 +1,33 @@ -import { constants, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'; import { realpath } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; -import { safePath } from './bundle.js'; import { canonicalize } from './canonical.js'; -import { descriptorIsFile } from './fs-descriptor.js'; import { createHostedBaseSnapshot, hostedBaseSourceDigest, removeHostedBaseSnapshot, type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; +import { + declarationSignature, + hostedDeclaredExtensions, +} from './hosted-extension-declarations.js'; import { findHostedProject } from './hosted-project.js'; import { assertHostedPromiseSafety, frozenHostedPromiseValue, } from './hosted-promise-safety.js'; -import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; -import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, type PluginLockEntry } from './plugin-lock.js'; -import { canonicalPluginRef, type PluginSourceRef } from './plugin-source.js'; import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; const INSTALLATION_AUTHORITY = new WeakSet(); const BASE_AUTHORITY = new WeakSet(); -const CLOSE_SYNC = closeSync; -const EXISTS_SYNC = existsSync; -const FSTAT_SYNC = fstatSync; -const OPEN_SYNC = openSync; -const READ_SYNC = readSync; const REALPATH = realpath; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_RESOLVE = resolve; -const ERROR = Error; const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; -const MAX_DECLARATION_BYTES = 1024 * 1024; -const BIG_INT = BigInt; -const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; -const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( - value: Buffer, - encoding: BufferEncoding, -) => string; -const NUMBER = Number; -const DECLARATION_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); -const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; -const DATE_PARSE = Date.parse; -const NUMBER_IS_NAN = Number.isNaN; const OBJECT_FREEZE = Object.freeze; -const OBJECT_HAS_OWN = Object.hasOwn; -const OBJECT_KEYS = Object.keys; -const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( - regexp: RegExp, - value: string, -) => boolean; -const SET = Set; -const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; -const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; -const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( - value: string, - search: string, -) => boolean; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( map: WeakMap, key: K, @@ -78,11 +45,6 @@ const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as , value: T, ) => boolean; -const LOCK_HEX64 = /^[0-9a-f]{64}$/; -const LOCK_SHA = /^[0-9a-f]{40}$/; -const LOCK_OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; -const LOCK_REPO = /^[A-Za-z0-9_.-]{1,100}$/; -const HTTPS_GITHUB = /^https:\/\/github\.com\//; interface RuntimeGeneration { readonly origin: string; @@ -91,7 +53,6 @@ interface RuntimeGeneration { sourceRoots: readonly HostedBaseSourceRoot[]; sourceSha256: string; } - const INSTALLATION_GENERATION = new WeakMap(); const BASE_GENERATION = new WeakMap(); @@ -240,212 +201,6 @@ function declaredExtensions(generation: RuntimeGeneration): { readonly signature }; } -function declarationSignature(declared: ReturnType): string { - const records: Array<{ - ref: string; - name: string; - version: string; - digest: string; - manifestSha256: string; - }> = []; - for (let index = 0; index < declared.length; index += 1) { - const { ref, entry } = declared[index]!; - records[index] = { - ref, - name: entry.name, - version: entry.version, - digest: entry.digest, - manifestSha256: entry.manifestSha256, - }; - } - return canonicalize(records); -} - -function hostedDeclaredExtensions( - root: string, -): readonly { ref: string; entry: PluginLockEntry; source: PluginSourceRef }[] { - if (EXISTS_SYNC(PATH_JOIN(root, 'flows.json.tmp')) || EXISTS_SYNC(PATH_JOIN(root, `${PLUGIN_LOCK_FILE}.tmp`))) { - throw new PluginError('plugin_lock_invalid', 'Hosted extension declarations have a pending transaction.'); - } - let config: unknown; - try { - config = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(PATH_JOIN(root, 'flows.json')), 'utf8')); - } catch (error) { - if (error instanceof PluginError) throw error; - throw new PluginError('plugin_manifest_invalid', 'Invalid or oversized flows.json.'); - } - if (typeof config !== 'object' || config === null || ARRAY_IS_ARRAY(config)) { - throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); - } - const plugins = OBJECT_HAS_OWN(config, 'plugins') - ? (config as { plugins?: unknown }).plugins - : undefined; - if (plugins !== undefined && !ARRAY_IS_ARRAY(plugins)) { - throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); - } - const declared: string[] = []; - if (plugins !== undefined) { - for (let index = 0; index < plugins.length; index += 1) { - const ref = plugins[index]; - if (typeof ref !== 'string') { - throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.'); - } - if (isHostedGithubPluginRef(ref)) declared[declared.length] = ref; - } - } - let lock: { readonly version: 2; readonly plugins: readonly PluginLockEntry[] } = OBJECT_FREEZE({ - version: PLUGIN_LOCK_VERSION, - plugins: OBJECT_FREEZE([]), - }); - const lockPath = PATH_JOIN(root, PLUGIN_LOCK_FILE); - if (EXISTS_SYNC(lockPath)) { - let value: unknown; - try { - value = JSON_PARSE(BUFFER_TO_STRING(readBoundedDeclaration(lockPath), 'utf8')); - } catch (error) { - if (error instanceof PluginError) throw error; - throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`); - } - lock = parseHostedPluginLock(value); - } - if (declared.length !== lock.plugins.length) { - throw new PluginError('plugin_lock_invalid', 'Hosted flows.json and flows.lock.json declarations differ.'); - } - const result: Array<{ - ref: string; - entry: PluginLockEntry; - source: PluginSourceRef; - }> = []; - for (let index = 0; index < lock.plugins.length; index += 1) { - const entry = lock.plugins[index]!; - const source = OBJECT_FREEZE({ ...entry.source, ref: entry.source.sha }); - const ref = canonicalPluginRef(source); - if (declared[index] !== ref) { - throw new PluginError('plugin_lock_invalid', 'Hosted flows.lock.json order differs from flows.json.plugins.'); - } - result[result.length] = OBJECT_FREEZE({ ref, entry, source }); - } - return OBJECT_FREEZE(result); -} - -function parseHostedPluginLock(input: unknown): { - readonly version: 2; - readonly plugins: readonly PluginLockEntry[]; -} { - let value: unknown; - try { - value = snapshotJsonValue(input, 'hosted plugin lock'); - } catch { - return invalidHostedLock(`expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`); - } - if (!hostedRecord(value) || value.version !== PLUGIN_LOCK_VERSION || !ARRAY_IS_ARRAY(value.plugins) - || !hostedHasOnlyKeys(value, ['version', 'plugins'])) { - return invalidHostedLock(`expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`); - } - const names = new SET(); - const plugins: PluginLockEntry[] = []; - for (let index = 0; index < value.plugins.length; index += 1) { - const entry = value.plugins[index]; - if (!hostedRecord(entry) - || !hostedHasOnlyKeys(entry, ['digest', 'kind', 'manifestSha256', 'name', 'order', 'resolvedAt', 'source', 'version']) - || entry.kind !== 'flow-extension' || typeof entry.name !== 'string' || typeof entry.version !== 'string' - || typeof entry.digest !== 'string' || !REGEXP_TEST(LOCK_HEX64, entry.digest) - || typeof entry.manifestSha256 !== 'string' || !REGEXP_TEST(LOCK_HEX64, entry.manifestSha256) - || entry.order !== index + 1 || typeof entry.resolvedAt !== 'string' - || NUMBER_IS_NAN(DATE_PARSE(entry.resolvedAt)) || !hostedRecord(entry.source) - || !hostedHasOnlyKeys(entry.source, ['host', 'owner', 'repo', 'sha', 'path']) - || entry.source.host !== 'github' || typeof entry.source.owner !== 'string' - || !REGEXP_TEST(LOCK_OWNER, entry.source.owner) || typeof entry.source.repo !== 'string' - || !REGEXP_TEST(LOCK_REPO, entry.source.repo) || entry.source.repo === '.' || entry.source.repo === '..' - || typeof entry.source.sha !== 'string' || !REGEXP_TEST(LOCK_SHA, entry.source.sha) - || typeof entry.source.path !== 'string' - || (entry.source.path !== '' && !safePath(entry.source.path))) { - return invalidHostedLock(`plugins[${index}] is malformed.`); - } - if (SET_HAS(names, entry.name)) return invalidHostedLock(`plugin ${entry.name} is listed twice.`); - SET_ADD(names, entry.name); - plugins[plugins.length] = OBJECT_FREEZE({ - name: entry.name, - kind: 'flow-extension', - version: entry.version, - source: OBJECT_FREEZE({ - host: 'github', - owner: entry.source.owner, - repo: entry.source.repo, - sha: entry.source.sha, - path: entry.source.path, - }), - digest: entry.digest, - manifestSha256: entry.manifestSha256, - order: entry.order, - resolvedAt: entry.resolvedAt, - }); - } - return OBJECT_FREEZE({ version: PLUGIN_LOCK_VERSION, plugins: OBJECT_FREEZE(plugins) }); -} - -function isHostedGithubPluginRef(value: string): boolean { - return STRING_STARTS_WITH(value, 'github:') || REGEXP_TEST(HTTPS_GITHUB, value); -} - -function hostedRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !ARRAY_IS_ARRAY(value); -} - -function hostedHasOnlyKeys(value: Record, allowed: readonly string[]): boolean { - const keys = OBJECT_KEYS(value); - for (let index = 0; index < keys.length; index += 1) { - let found = false; - for (let allowedIndex = 0; allowedIndex < allowed.length; allowedIndex += 1) { - if (keys[index] === allowed[allowedIndex]) { - found = true; - break; - } - } - if (!found) return false; - } - return true; -} - -function invalidHostedLock(message: string): never { - throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: ${message}`); -} - -function readBoundedDeclaration(path: string): Buffer { - let descriptor: number | undefined; - try { - descriptor = OPEN_SYNC(path, DECLARATION_READ_FLAGS); - const before = FSTAT_SYNC(descriptor, { bigint: true }); - if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(MAX_DECLARATION_BYTES)) { - throw new PluginError('plugin_source_invalid', 'Hosted extension declaration is not a bounded regular file.'); - } - const expected = NUMBER(before.size); - const bytes = BUFFER_ALLOC_UNSAFE(expected); - let offset = 0; - while (offset < expected) { - const count = READ_SYNC(descriptor, bytes, offset, expected - offset, offset); - if (count === 0) throw new ERROR('short read'); - offset += count; - } - if (READ_SYNC(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) { - throw new ERROR('grew'); - } - const after = FSTAT_SYNC(descriptor, { bigint: true }); - if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { - throw new ERROR('changed'); - } - return bytes; - } catch (error) { - if (error instanceof PluginError) throw error; - throw new PluginError( - 'plugin_source_invalid', - 'Hosted extension declaration is unreadable or changed while reading.', - ); - } finally { - if (descriptor !== undefined) CLOSE_SYNC(descriptor); - } -} - function newGeneration(origin: string): RuntimeGeneration { return { origin, diff --git a/packages/sdk/src/hosted-promise-safety.ts b/packages/sdk/src/hosted-promise-safety.ts index a6c6024ac..65e1578cc 100644 --- a/packages/sdk/src/hosted-promise-safety.ts +++ b/packages/sdk/src/hosted-promise-safety.ts @@ -1,9 +1,19 @@ -import { PluginError, type PluginFailureKind } from './plugin-manifest.js'; +import { Dir, Dirent, Stats } from "node:fs"; +import { PluginError, type PluginFailureKind } from "./plugin-manifest.js"; const OBJECT_DEFINE_PROPERTY = Object.defineProperty; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_PROTOTYPE = Object.prototype; +const PROTECTED_PROMISE_PROTOTYPES: readonly object[] = [ + OBJECT_PROTOTYPE, + Array.prototype, + Buffer.prototype, + Uint8Array.prototype, + Dir.prototype, + Dirent.prototype, + Stats.prototype, +]; const INERT_THEN = { configurable: false, enumerable: false, @@ -11,12 +21,17 @@ const INERT_THEN = { writable: false, } as const; const PROTOTYPE_THEN_GET = () => undefined; -const PROTOTYPE_THEN_SET = function setOwnThen(this: object, value: unknown): void { - if (this === OBJECT_PROTOTYPE) return; +const PROTOTYPE_THEN_SET = function setOwnThen( + this: object, + value: unknown, +): void { + for (let index = 0; index < PROTECTED_PROMISE_PROTOTYPES.length; index += 1) { + if (this === PROTECTED_PROMISE_PROTOTYPES[index]) return; + } // Preserve ordinary `object.then = value` behavior for dependencies while // keeping the inherited slot immutable. Assignment reaches this setter only // when the receiver does not already have its own `then` property. - OBJECT_DEFINE_PROPERTY(this, 'then', { + OBJECT_DEFINE_PROPERTY(this, "then", { configurable: true, enumerable: true, value, @@ -30,17 +45,27 @@ const LOCKED_PROTOTYPE_THEN = { set: PROTOTYPE_THEN_SET, } as const; -// Promise assimilation consults the prototype after every async operation, -// not just at the public entry point. Lock this one dangerous slot while the -// realm is pristine so authored code cannot install a thenable between awaits. -const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); -if (initialThen === undefined) OBJECT_DEFINE_PROPERTY(OBJECT_PROTOTYPE, 'then', LOCKED_PROTOTYPE_THEN); -const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); -const PROMISE_PROTOTYPE_SAFE = installedThen !== undefined - && installedThen.get === PROTOTYPE_THEN_GET - && installedThen.set === PROTOTYPE_THEN_SET - && installedThen.configurable === false - && installedThen.enumerable === false; +// Promise assimilation consults the nearest prototype after every async +// operation. Lock the prototypes returned by hosted Node APIs while the realm +// is pristine so a later, more-specific hook cannot bypass Object.prototype. +let promisePrototypesSafe = true; +for (let index = 0; index < PROTECTED_PROMISE_PROTOTYPES.length; index += 1) { + const prototype = PROTECTED_PROMISE_PROTOTYPES[index]!; + const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(prototype, "then"); + if (initialThen === undefined) + OBJECT_DEFINE_PROPERTY(prototype, "then", LOCKED_PROTOTYPE_THEN); + const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(prototype, "then"); + if ( + installedThen === undefined || + installedThen.get !== PROTOTYPE_THEN_GET || + installedThen.set !== PROTOTYPE_THEN_SET || + installedThen.configurable !== false || + installedThen.enumerable !== false + ) { + promisePrototypesSafe = false; + } +} +const PROMISE_PROTOTYPES_SAFE = promisePrototypesSafe; /** * Promise resolution reads a returned object's inherited `then` property. @@ -49,20 +74,22 @@ const PROMISE_PROTOTYPE_SAFE = installedThen !== undefined */ export function assertHostedPromiseSafety( code: PluginFailureKind, - message = 'Hosted isolation refuses an ambient Object.prototype.then.', + message = "Hosted isolation refuses an ambient promise-result prototype thenable.", ): void { - if (!PROMISE_PROTOTYPE_SAFE) { + if (!PROMISE_PROTOTYPES_SAFE) { throw new PluginError(code, message); } } /** Shadow inherited thenables before an object is used to settle a promise. */ export function hostedPromiseValue(value: T): T { - OBJECT_DEFINE_PROPERTY(value, 'then', INERT_THEN); + OBJECT_DEFINE_PROPERTY(value, "then", INERT_THEN); return value; } /** Freeze a behavior-free object after shadowing inherited thenables. */ -export function frozenHostedPromiseValue(value: T): Readonly { +export function frozenHostedPromiseValue( + value: T, +): Readonly { return OBJECT_FREEZE(hostedPromiseValue(value)); } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 38c76bb3e..4c9fff26c 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -18,6 +18,7 @@ import { } from './fs-descriptor.js'; import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; +import { frozenHostedPromiseValue } from './hosted-promise-safety.js'; import { PluginError } from './plugin-manifest.js'; /** @@ -76,7 +77,6 @@ const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants export function pluginStoreDirectory(root: string, name: string, digest: string): string { return PATH_JOIN(PATH_RESOLVE(root), PLUGIN_STORE, `${name}@sha256:${digest}`); } - export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array; diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index db9c6c597..4108d625e 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -39,7 +39,7 @@ describe('hosted base private snapshot', () => { } }); - it('locks the inherited then slot before authored code can schedule a replacement', async () => { + it('locks every hosted promise-result prototype before authored code can schedule a replacement', async () => { const { flowPath } = fixture(); let poisonCalls = 0; const locked = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); @@ -55,6 +55,19 @@ describe('hosted base private snapshot', () => { }, }); }).toThrow(TypeError); + expect(Object.getOwnPropertyDescriptor(Array.prototype, 'then')).toMatchObject({ + configurable: false, + enumerable: false, + }); + expect(() => { + Object.defineProperty(Array.prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + }).toThrow(TypeError); const snapshot = await createHostedBaseSnapshot(flowPath); await removeHostedBaseSnapshot(snapshot); expect(poisonCalls).toBe(0); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 7b2b9e0cc..86fbcd15a 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -20,6 +20,12 @@ async function fixture() { } describe('bounded plugin-store verification', () => { + it('shadows inherited thenables on the verified stored-file array', async () => { + const stored = await fixture(); + const files = await readStoredPluginFiles(stored.directory, stored.digest); + expect(Object.getOwnPropertyDescriptor(files, 'then')).toMatchObject({ value: undefined }); + }); + it('hashes the exact supplied buffers without ambient sort or map', () => { const sort = Array.prototype.sort; const map = Array.prototype.map; From 7dfe3e8039ddcf8e784784b1a188e2a8e4c57112 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:56:12 -0700 Subject: [PATCH 64/80] docs(evidence): record prototype safety gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 231377af5..891b9556d 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1589,3 +1589,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.s5kEqR/flows --help >/dev/null $ printf 'standalone-hosted-invariants-pass %s\n' /tmp/flows-pr552-standalone.s5kEqR/flows standalone-hosted-invariants-pass /tmp/flows-pr552-standalone.s5kEqR/flows + +$ git rev-parse HEAD +ae294841 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 223 passed (223) + Start at 16:55:30 + Duration 2.28s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.tWeOS8/flows +Bundled 781 modules in 30ms +authored-node.mjs 3.1 MB (entry point) +[54ms] bundle 714 modules +[92ms] compile /tmp/flows-pr552-standalone.tWeOS8/flows + +$ /tmp/flows-pr552-standalone.tWeOS8/flows --help >/dev/null +$ printf 'standalone-hosted-prototype-pass %s\n' /tmp/flows-pr552-standalone.tWeOS8/flows +standalone-hosted-prototype-pass /tmp/flows-pr552-standalone.tWeOS8/flows From 3c0c3757aaafa62d1fbc0fadfb1094c3ceac208d Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:58:38 -0700 Subject: [PATCH 65/80] fix(sdk): retain narrower promise shielding Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 ----------- packages/sdk/src/hosted-promise-safety.ts | 65 ++++++------------- packages/sdk/src/plugin-store.ts | 2 +- .../sdk/tests/hosted-base-snapshot.test.ts | 15 +---- .../sdk/tests/plugin-store-bounds.test.ts | 6 -- 5 files changed, 21 insertions(+), 104 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 891b9556d..231377af5 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1589,40 +1589,3 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.s5kEqR/flows --help >/dev/null $ printf 'standalone-hosted-invariants-pass %s\n' /tmp/flows-pr552-standalone.s5kEqR/flows standalone-hosted-invariants-pass /tmp/flows-pr552-standalone.s5kEqR/flows - -$ git rev-parse HEAD -ae294841 - -$ cd packages/sdk -$ git diff --check -$ npm run typecheck - -> @relayflows/sdk@2.0.28 typecheck -> tsc --noEmit && tsc -p tsconfig.type-tests.json - -$ npm run build - -> @relayflows/sdk@2.0.28 build -> tsc && node scripts/make-cli-executable.mjs - -$ npm run typecheck:tests - -> @relayflows/sdk@2.0.28 typecheck:tests -> tsc -p tsconfig.tests.json - -$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts - - Test Files 9 passed (9) - Tests 223 passed (223) - Start at 16:55:30 - Duration 2.28s - -$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.tWeOS8/flows -Bundled 781 modules in 30ms -authored-node.mjs 3.1 MB (entry point) -[54ms] bundle 714 modules -[92ms] compile /tmp/flows-pr552-standalone.tWeOS8/flows - -$ /tmp/flows-pr552-standalone.tWeOS8/flows --help >/dev/null -$ printf 'standalone-hosted-prototype-pass %s\n' /tmp/flows-pr552-standalone.tWeOS8/flows -standalone-hosted-prototype-pass /tmp/flows-pr552-standalone.tWeOS8/flows diff --git a/packages/sdk/src/hosted-promise-safety.ts b/packages/sdk/src/hosted-promise-safety.ts index 65e1578cc..a6c6024ac 100644 --- a/packages/sdk/src/hosted-promise-safety.ts +++ b/packages/sdk/src/hosted-promise-safety.ts @@ -1,19 +1,9 @@ -import { Dir, Dirent, Stats } from "node:fs"; -import { PluginError, type PluginFailureKind } from "./plugin-manifest.js"; +import { PluginError, type PluginFailureKind } from './plugin-manifest.js'; const OBJECT_DEFINE_PROPERTY = Object.defineProperty; const OBJECT_FREEZE = Object.freeze; const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; const OBJECT_PROTOTYPE = Object.prototype; -const PROTECTED_PROMISE_PROTOTYPES: readonly object[] = [ - OBJECT_PROTOTYPE, - Array.prototype, - Buffer.prototype, - Uint8Array.prototype, - Dir.prototype, - Dirent.prototype, - Stats.prototype, -]; const INERT_THEN = { configurable: false, enumerable: false, @@ -21,17 +11,12 @@ const INERT_THEN = { writable: false, } as const; const PROTOTYPE_THEN_GET = () => undefined; -const PROTOTYPE_THEN_SET = function setOwnThen( - this: object, - value: unknown, -): void { - for (let index = 0; index < PROTECTED_PROMISE_PROTOTYPES.length; index += 1) { - if (this === PROTECTED_PROMISE_PROTOTYPES[index]) return; - } +const PROTOTYPE_THEN_SET = function setOwnThen(this: object, value: unknown): void { + if (this === OBJECT_PROTOTYPE) return; // Preserve ordinary `object.then = value` behavior for dependencies while // keeping the inherited slot immutable. Assignment reaches this setter only // when the receiver does not already have its own `then` property. - OBJECT_DEFINE_PROPERTY(this, "then", { + OBJECT_DEFINE_PROPERTY(this, 'then', { configurable: true, enumerable: true, value, @@ -45,27 +30,17 @@ const LOCKED_PROTOTYPE_THEN = { set: PROTOTYPE_THEN_SET, } as const; -// Promise assimilation consults the nearest prototype after every async -// operation. Lock the prototypes returned by hosted Node APIs while the realm -// is pristine so a later, more-specific hook cannot bypass Object.prototype. -let promisePrototypesSafe = true; -for (let index = 0; index < PROTECTED_PROMISE_PROTOTYPES.length; index += 1) { - const prototype = PROTECTED_PROMISE_PROTOTYPES[index]!; - const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(prototype, "then"); - if (initialThen === undefined) - OBJECT_DEFINE_PROPERTY(prototype, "then", LOCKED_PROTOTYPE_THEN); - const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(prototype, "then"); - if ( - installedThen === undefined || - installedThen.get !== PROTOTYPE_THEN_GET || - installedThen.set !== PROTOTYPE_THEN_SET || - installedThen.configurable !== false || - installedThen.enumerable !== false - ) { - promisePrototypesSafe = false; - } -} -const PROMISE_PROTOTYPES_SAFE = promisePrototypesSafe; +// Promise assimilation consults the prototype after every async operation, +// not just at the public entry point. Lock this one dangerous slot while the +// realm is pristine so authored code cannot install a thenable between awaits. +const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +if (initialThen === undefined) OBJECT_DEFINE_PROPERTY(OBJECT_PROTOTYPE, 'then', LOCKED_PROTOTYPE_THEN); +const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +const PROMISE_PROTOTYPE_SAFE = installedThen !== undefined + && installedThen.get === PROTOTYPE_THEN_GET + && installedThen.set === PROTOTYPE_THEN_SET + && installedThen.configurable === false + && installedThen.enumerable === false; /** * Promise resolution reads a returned object's inherited `then` property. @@ -74,22 +49,20 @@ const PROMISE_PROTOTYPES_SAFE = promisePrototypesSafe; */ export function assertHostedPromiseSafety( code: PluginFailureKind, - message = "Hosted isolation refuses an ambient promise-result prototype thenable.", + message = 'Hosted isolation refuses an ambient Object.prototype.then.', ): void { - if (!PROMISE_PROTOTYPES_SAFE) { + if (!PROMISE_PROTOTYPE_SAFE) { throw new PluginError(code, message); } } /** Shadow inherited thenables before an object is used to settle a promise. */ export function hostedPromiseValue(value: T): T { - OBJECT_DEFINE_PROPERTY(value, "then", INERT_THEN); + OBJECT_DEFINE_PROPERTY(value, 'then', INERT_THEN); return value; } /** Freeze a behavior-free object after shadowing inherited thenables. */ -export function frozenHostedPromiseValue( - value: T, -): Readonly { +export function frozenHostedPromiseValue(value: T): Readonly { return OBJECT_FREEZE(hostedPromiseValue(value)); } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 4c9fff26c..38c76bb3e 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -18,7 +18,6 @@ import { } from './fs-descriptor.js'; import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; -import { frozenHostedPromiseValue } from './hosted-promise-safety.js'; import { PluginError } from './plugin-manifest.js'; /** @@ -77,6 +76,7 @@ const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants export function pluginStoreDirectory(root: string, name: string, digest: string): string { return PATH_JOIN(PATH_RESOLVE(root), PLUGIN_STORE, `${name}@sha256:${digest}`); } + export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array; diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index 4108d625e..db9c6c597 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -39,7 +39,7 @@ describe('hosted base private snapshot', () => { } }); - it('locks every hosted promise-result prototype before authored code can schedule a replacement', async () => { + it('locks the inherited then slot before authored code can schedule a replacement', async () => { const { flowPath } = fixture(); let poisonCalls = 0; const locked = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); @@ -55,19 +55,6 @@ describe('hosted base private snapshot', () => { }, }); }).toThrow(TypeError); - expect(Object.getOwnPropertyDescriptor(Array.prototype, 'then')).toMatchObject({ - configurable: false, - enumerable: false, - }); - expect(() => { - Object.defineProperty(Array.prototype, 'then', { - configurable: true, - get() { - poisonCalls += 1; - return undefined; - }, - }); - }).toThrow(TypeError); const snapshot = await createHostedBaseSnapshot(flowPath); await removeHostedBaseSnapshot(snapshot); expect(poisonCalls).toBe(0); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 86fbcd15a..7b2b9e0cc 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -20,12 +20,6 @@ async function fixture() { } describe('bounded plugin-store verification', () => { - it('shadows inherited thenables on the verified stored-file array', async () => { - const stored = await fixture(); - const files = await readStoredPluginFiles(stored.directory, stored.digest); - expect(Object.getOwnPropertyDescriptor(files, 'then')).toMatchObject({ value: undefined }); - }); - it('hashes the exact supplied buffers without ambient sort or map', () => { const sort = Array.prototype.sort; const map = Array.prototype.map; From 5b4cb443afd2f0a1ccfc36e98ba0857794f09823 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 16:59:12 -0700 Subject: [PATCH 66/80] docs(evidence): record native promise gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 231377af5..ee10bed56 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1589,3 +1589,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.s5kEqR/flows --help >/dev/null $ printf 'standalone-hosted-invariants-pass %s\n' /tmp/flows-pr552-standalone.s5kEqR/flows standalone-hosted-invariants-pass /tmp/flows-pr552-standalone.s5kEqR/flows + +$ git rev-parse HEAD +ebf2f280 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 225 passed (225) + Start at 16:58:18 + Duration 2.54s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.yoEPLj/flows +Bundled 781 modules in 27ms +authored-node.mjs 3.1 MB (entry point) +[58ms] bundle 714 modules +[101ms] compile /tmp/flows-pr552-standalone.yoEPLj/flows + +$ /tmp/flows-pr552-standalone.yoEPLj/flows --help >/dev/null +$ printf 'standalone-native-promise-pass %s\n' /tmp/flows-pr552-standalone.yoEPLj/flows +standalone-native-promise-pass /tmp/flows-pr552-standalone.yoEPLj/flows From 0e459922f08a3f5e57a3217c33a214bc75a07142 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:21:33 -0700 Subject: [PATCH 67/80] fix(sdk): bypass inherited array setters Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/bundle.ts | 5 ++-- packages/sdk/src/canonical.ts | 6 ++-- packages/sdk/src/hosted-base-snapshot.ts | 10 +++---- .../sdk/src/hosted-extension-declarations.ts | 13 ++++---- .../sdk/src/hosted-extension-isolation.ts | 3 +- packages/sdk/src/hosted-extension-manifest.ts | 9 +++--- packages/sdk/src/hosted-extension-runtime.ts | 7 +++-- packages/sdk/src/hosted-extension-sandbox.ts | 30 +++++++++---------- packages/sdk/src/intrinsic-array.ts | 14 +++++++++ packages/sdk/src/json-value.ts | 4 +-- packages/sdk/src/plugin-store.ts | 5 ++-- .../sdk/tests/hosted-base-snapshot.test.ts | 30 +++++++++++++++++++ 12 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 packages/sdk/src/intrinsic-array.ts diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index 7eb49567b..f6d193936 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -2,6 +2,7 @@ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verif import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; type Hash = ReturnType; const CREATE_HASH = createHash; @@ -59,7 +60,7 @@ export function safePath(path: string): boolean { */ export function payloadManifest(files: readonly { path: string; data: Uint8Array }[]): string { const sorted: { path: string; data: Uint8Array }[] = []; - for (let index = 0; index < files.length; index += 1) sorted[index] = files[index]!; + for (let index = 0; index < files.length; index += 1) appendIntrinsicArray(sorted, files[index]!); // Do not consult mutable Array prototype methods here. Authored base code // executes in this process before hosted artifact staging, so a live // sort/map lookup would let it substitute bytes or manifest records. @@ -75,7 +76,7 @@ export function payloadManifest(files: readonly { path: string; data: Uint8Array const entries: { path: string; sha256: string; bytes: number }[] = []; for (let index = 0; index < sorted.length; index += 1) { const file = sorted[index]!; - entries[index] = { path: file.path, sha256: sha256(file.data), bytes: TYPED_ARRAY_LENGTH(file.data) }; + appendIntrinsicArray(entries, { path: file.path, sha256: sha256(file.data), bytes: TYPED_ARRAY_LENGTH(file.data) }); } return canonicalize(entries); } diff --git a/packages/sdk/src/canonical.ts b/packages/sdk/src/canonical.ts index 16072cc8e..3397704bb 100644 --- a/packages/sdk/src/canonical.ts +++ b/packages/sdk/src/canonical.ts @@ -9,12 +9,12 @@ import { createHash } from 'node:crypto'; import { snapshotJsonValue, type JsonValue } from './json-value.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; const ARRAY_IS_ARRAY = Array.isArray; const ARRAY_JOIN = Function.prototype.call.bind(Array.prototype.join) as ( array: readonly string[], separator?: string, ) => string; -const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as (array: T[]) => T[]; const JSON_STRINGIFY = JSON.stringify; const OBJECT_KEYS = Object.keys; @@ -55,7 +55,7 @@ function serialize(value: JsonValue): string { } if (ARRAY_IS_ARRAY(value)) { const items: string[] = []; - for (let index = 0; index < value.length; index += 1) items[index] = serialize(value[index]!); + for (let index = 0; index < value.length; index += 1) appendIntrinsicArray(items, serialize(value[index]!)); return '[' + ARRAY_JOIN(items, ',') + ']'; } const parts: string[] = []; @@ -65,7 +65,7 @@ function serialize(value: JsonValue): string { const key = keys[index]!; const child = value[key]; if (child === undefined) continue; - ARRAY_PUSH(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); + appendIntrinsicArray(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); } return '{' + ARRAY_JOIN(parts, ',') + '}'; } diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts index 6b728913f..284d7f4a2 100644 --- a/packages/sdk/src/hosted-base-snapshot.ts +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -19,6 +19,7 @@ import { statDescriptor, } from './fs-descriptor.js'; import { findHostedProject } from './hosted-project.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { assertHostedPromiseSafety, frozenHostedPromiseValue, @@ -41,7 +42,6 @@ const PATH_RELATIVE = relative; const PATH_RESOLVE = resolve; const PATH_SEPARATOR = sep; const PROCESS_PLATFORM = process.platform; -const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( array: T[], compare?: (left: T, right: T) => number, @@ -180,7 +180,7 @@ async function readAuthorityFiles( const source = sources[sourceIndex]!; const sourceFiles = await readTree(source.root, source.prefix, budget, hooks); for (let fileIndex = 0; fileIndex < sourceFiles.length; fileIndex += 1) { - ARRAY_PUSH(files, sourceFiles[fileIndex]!); + appendIntrinsicArray(files, sourceFiles[fileIndex]!); } } ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); @@ -213,7 +213,7 @@ async function readSnapshotTree(root: string): Promise { throw invalid(`Hosted base snapshot changed while reading "${path}".`); } budget.bytes += expectedBytes; - ARRAY_PUSH(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); + appendIntrinsicArray(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); } finally { await closeDescriptor(descriptor); } @@ -263,7 +263,7 @@ async function readTree( throw invalid(`Hosted base source changed while reading "${relativePath}".`); } budget.bytes += expectedBytes; - ARRAY_PUSH( + appendIntrinsicArray( files, OBJECT_FREEZE({ path: prefix === '' ? relativePath : PATH_JOIN(prefix, relativePath), @@ -334,7 +334,7 @@ function sourceDigest(files: readonly SourceFile[]): string { const records: Array<{ path: string; sha256: string }> = []; for (let index = 0; index < files.length; index += 1) { const file = files[index]!; - records[index] = { path: file.path, sha256: file.sha256 }; + appendIntrinsicArray(records, { path: file.path, sha256: file.sha256 }); } return sha256(canonicalize(records)); } diff --git a/packages/sdk/src/hosted-extension-declarations.ts b/packages/sdk/src/hosted-extension-declarations.ts index be6c5d526..d3571407a 100644 --- a/packages/sdk/src/hosted-extension-declarations.ts +++ b/packages/sdk/src/hosted-extension-declarations.ts @@ -12,6 +12,7 @@ import { canonicalize } from "./canonical.js"; import { descriptorIsFile } from "./fs-descriptor.js"; import { snapshotJsonValue } from "./json-value.js"; import { PluginError } from "./plugin-manifest.js"; +import { appendIntrinsicArray } from "./intrinsic-array.js"; import { PLUGIN_LOCK_FILE, PLUGIN_LOCK_VERSION, @@ -88,13 +89,13 @@ export function declarationSignature( }> = []; for (let index = 0; index < declared.length; index += 1) { const { ref, entry } = declared[index]!; - records[index] = { + appendIntrinsicArray(records, { ref, name: entry.name, version: entry.version, digest: entry.digest, manifestSha256: entry.manifestSha256, - }; + }); } return canonicalize(records); } @@ -151,7 +152,7 @@ export function hostedDeclaredExtensions( "flows.json plugins must be strings.", ); } - if (isHostedGithubPluginRef(ref)) declared[declared.length] = ref; + if (isHostedGithubPluginRef(ref)) appendIntrinsicArray(declared, ref); } } let lock: { @@ -194,7 +195,7 @@ export function hostedDeclaredExtensions( "Hosted flows.lock.json order differs from flows.json.plugins.", ); } - result[result.length] = OBJECT_FREEZE({ ref, entry, source }); + appendIntrinsicArray(result, OBJECT_FREEZE({ ref, entry, source })); } return OBJECT_FREEZE(result); } @@ -272,7 +273,7 @@ function parseHostedPluginLock(input: unknown): { if (SET_HAS(names, entry.name)) return invalidHostedLock(`plugin ${entry.name} is listed twice.`); SET_ADD(names, entry.name); - plugins[plugins.length] = OBJECT_FREEZE({ + appendIntrinsicArray(plugins, OBJECT_FREEZE({ name: entry.name, kind: "flow-extension", version: entry.version, @@ -287,7 +288,7 @@ function parseHostedPluginLock(input: unknown): { manifestSha256: entry.manifestSha256, order: entry.order, resolvedAt: entry.resolvedAt, - }); + })); } return OBJECT_FREEZE({ version: PLUGIN_LOCK_VERSION, diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts index d69962a8d..5e48a2f41 100644 --- a/packages/sdk/src/hosted-extension-isolation.ts +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -19,6 +19,7 @@ import { assertHostedPromiseSafety, frozenHostedPromiseValue, } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { readStoredPluginFiles } from './plugin-store.js'; import { boundedJsonSnapshot, @@ -208,7 +209,7 @@ export async function selectHostedExtensionForRuntime( const manifest = await verifiedManifest(artifact); assertCompatible(manifest, versions); assertBaseCompatible(manifest, base); - if (hostedManifestRoutes(manifest, identity)) matches[matches.length] = { artifact, manifest }; + if (hostedManifestRoutes(manifest, identity)) appendIntrinsicArray(matches, { artifact, manifest }); } if (matches.length > 1) { throw new PluginError( diff --git a/packages/sdk/src/hosted-extension-manifest.ts b/packages/sdk/src/hosted-extension-manifest.ts index c4b191072..519587f30 100644 --- a/packages/sdk/src/hosted-extension-manifest.ts +++ b/packages/sdk/src/hosted-extension-manifest.ts @@ -7,6 +7,7 @@ import type { } from './flow-extension-manifest.js'; import { snapshotJsonValue } from './json-value.js'; import { frozenHostedPromiseValue } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { PluginError } from './plugin-manifest.js'; const ARRAY_IS_ARRAY = Array.isArray; @@ -132,7 +133,7 @@ function compatShape(value: unknown): FlowExtensionCompat { for (let prior = 0; prior < base.length; prior += 1) { if (base[prior]!.name === entry.name) return invalid('compat.base names a base flow twice.'); } - base[base.length] = OBJECT_FREEZE({ name: entry.name, version: entry.version }); + appendIntrinsicArray(base, OBJECT_FREEZE({ name: entry.name, version: entry.version })); } return OBJECT_FREEZE({ surface: value.surface, sdk: value.sdk, base: OBJECT_FREEZE(base) }); } @@ -152,11 +153,11 @@ function triggerShapes(value: unknown): readonly FlowExtensionTrigger[] { return invalid(`Trigger ${entry.provider}:${entry.event} is declared twice.`); } } - output[output.length] = OBJECT_FREEZE({ + appendIntrinsicArray(output, OBJECT_FREEZE({ provider: entry.provider, event: entry.event, actions: stringList(entry.actions, `triggers ${entry.provider}.${entry.event} actions`, IDENTIFIER), - }); + })); } return OBJECT_FREEZE(output); } @@ -215,7 +216,7 @@ function stringList(value: unknown, what: string, pattern: RegExp): readonly str for (let prior = 0; prior < output.length; prior += 1) { if (output[prior] === entry) return invalid(`${what} lists ${entry} twice.`); } - output[output.length] = entry; + appendIntrinsicArray(output, entry); } return OBJECT_FREEZE(output); } diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index e251f4e6d..9fa18aabd 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -7,6 +7,7 @@ import { removeHostedBaseSnapshot, type HostedBaseSourceRoot, } from './hosted-base-snapshot.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { declarationSignature, hostedDeclaredExtensions, @@ -179,14 +180,14 @@ async function installationAt( const { ref, entry } = declared[index]!; const directory = pluginStoreDirectory(root, entry.name, entry.digest); await verifyStoredPlugin(directory, entry.digest); - artifacts[artifacts.length] = OBJECT_FREEZE({ + appendIntrinsicArray(artifacts, OBJECT_FREEZE({ ref, name: entry.name, version: entry.version, directory, digest: entry.digest, manifestSha256: entry.manifestSha256, - }); + })); } return frozenHostedPromiseValue({ installation: installation(artifacts, generation), @@ -257,7 +258,7 @@ function installation( generation: RuntimeGeneration, ): HostedExtensionInstallation { const artifactCopy: HostedExtensionArtifact[] = []; - for (let index = 0; index < artifacts.length; index += 1) artifactCopy[index] = artifacts[index]!; + for (let index = 0; index < artifacts.length; index += 1) appendIntrinsicArray(artifactCopy, artifacts[index]!); const value = frozenHostedPromiseValue({ artifacts: frozenHostedPromiseValue(artifactCopy) }); WEAK_SET_ADD(INSTALLATION_AUTHORITY, value); WEAK_MAP_SET(INSTALLATION_GENERATION, value, generation); diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 67aec1fda..15bd751cb 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -8,6 +8,7 @@ import { payloadManifest, sha256 } from './bundle.js'; import { descriptorIsFile } from './fs-descriptor.js'; import { PluginError } from './plugin-manifest.js'; import { assertHostedPromiseSafety } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; import { exchangeHostedExtension, @@ -36,7 +37,6 @@ const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( child: ChildProcess, signal?: NodeJS.Signals | number, ) => boolean; -const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], ...values: T[]) => number; const BUFFER_FROM = Buffer.from; const JSON_PARSE = JSON.parse; const OBJECT_ENTRIES = Object.entries; @@ -135,7 +135,7 @@ export async function runHostedExtensionSandbox( const payloadFiles: { path: string; data: Buffer }[] = []; for (let index = 0; index < storedFiles.length; index += 1) { const file = storedFiles[index]!; - if (file.path !== 'manifest.json') payloadFiles[payloadFiles.length] = file; + if (file.path !== 'manifest.json') appendIntrinsicArray(payloadFiles, file); } if (sha256(payloadManifest(payloadFiles)) !== options.artifactDigest) { throw new PluginError( @@ -149,25 +149,25 @@ export async function runHostedExtensionSandbox( }]; const surfaceFiles = readSurfaceFiles(surfaceRoot); for (let index = 0; index < surfaceFiles.length; index += 1) { - dataFiles[dataFiles.length] = surfaceFiles[index]!; + appendIntrinsicArray(dataFiles, surfaceFiles[index]!); } for (let index = 0; index < payloadFiles.length; index += 1) { const file = payloadFiles[index]!; - dataFiles[dataFiles.length] = { + appendIntrinsicArray(dataFiles, { destination: `/extension/src/${file.path}`, bytes: file.data, - }; + }); } const destinations: string[] = []; for (let index = 0; index < dataFiles.length; index += 1) { - destinations[index] = dataFiles[index]!.destination; + appendIntrinsicArray(destinations, dataFiles[index]!.destination); } const args = sandboxArguments({ node, dataDestinations: destinations }); await ownOption<() => Promise>(options, 'beforeLaunch')?.(); const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; - for (let index = 0; index < args.length; index += 1) commandArgs[commandArgs.length] = args[index]!; + for (let index = 0; index < args.length; index += 1) appendIntrinsicArray(commandArgs, args[index]!); const stdio: Array<'pipe' | 'ignore'> = ['pipe', 'ignore', 'pipe', 'pipe']; - for (let index = 0; index < dataFiles.length; index += 1) stdio[stdio.length] = 'pipe'; + for (let index = 0; index < dataFiles.length; index += 1) appendIntrinsicArray(stdio, 'pipe'); const child = SPAWN(prlimit, commandArgs, { cwd: '/', env: {}, stdio }); for (let index = 0; index < dataFiles.length; index += 1) { const feed = child.stdio[index + 4] as Writable; @@ -252,10 +252,10 @@ function readSurfaceFiles(surfaceRoot: string): SandboxDataFile[] { if (sha256(bytes) !== expected) { return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); } - files[files.length] = { + appendIntrinsicArray(files, { destination: `/extension/node_modules/@relayflows/surface/dist/${file}`, bytes, - }; + }); } return files; } @@ -270,9 +270,9 @@ export function sandboxArguments(input: { const libraryPaths = ['/usr/lib', '/usr/lib64', '/lib', '/lib64']; for (let index = 0; index < libraryPaths.length; index += 1) { const path = libraryPaths[index]!; - if (EXISTS_SYNC(path)) ARRAY_PUSH(args, '--ro-bind', REALPATH_SYNC(path), path); + if (EXISTS_SYNC(path)) appendIntrinsicArray(args, '--ro-bind', REALPATH_SYNC(path), path); } - ARRAY_PUSH(args, + appendIntrinsicArray(args, '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/runtime', '--ro-bind', input.node, '/runtime/node', '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', @@ -299,14 +299,14 @@ export function sandboxArguments(input: { directory += `/${parts[partIndex]!}`; if (!SET_HAS(directories, directory)) { SET_ADD(directories, directory); - ARRAY_PUSH(args, '--dir', directory); + appendIntrinsicArray(args, '--dir', directory); } } } for (let index = 0; index < input.dataDestinations.length; index += 1) { - ARRAY_PUSH(args, '--perms', '0400', '--ro-bind-data', STRING(index + 4), input.dataDestinations[index]!); + appendIntrinsicArray(args, '--perms', '0400', '--ro-bind-data', STRING(index + 4), input.dataDestinations[index]!); } - ARRAY_PUSH(args, + appendIntrinsicArray(args, '--chdir', '/extension/src', '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', diff --git a/packages/sdk/src/intrinsic-array.ts b/packages/sdk/src/intrinsic-array.ts new file mode 100644 index 000000000..b19fbd1cb --- /dev/null +++ b/packages/sdk/src/intrinsic-array.ts @@ -0,0 +1,14 @@ +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; + +/** Append without consulting an inherited numeric setter on Array.prototype. */ +export function appendIntrinsicArray(array: T[], ...values: T[]): number { + for (let index = 0; index < values.length; index += 1) { + OBJECT_DEFINE_PROPERTY(array, array.length, { + configurable: true, + enumerable: true, + value: values[index]!, + writable: true, + }); + } + return array.length; +} diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index c9864a18c..9785c51b7 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -1,7 +1,7 @@ import { isProxy } from 'node:util/types'; +import { appendIntrinsicArray } from './intrinsic-array.js'; const ARRAY_IS_ARRAY = Array.isArray; -const ARRAY_PUSH = Function.prototype.call.bind(Array.prototype.push) as (array: T[], value: T) => number; const ARRAY_PROTOTYPE = Array.prototype; const ERROR = Error; const IS_PROXY = isProxy; @@ -133,7 +133,7 @@ function snapshotArray( if (index > 0) consumeBytes(budget, 1, at); const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, STRING(index)); if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); - ARRAY_PUSH(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); + appendIntrinsicArray(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); } // JSON.stringify consults `toJSON` before applying array semantics. Shadow // any poisoned Array.prototype hook with inert, non-JSON-visible data while diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 38c76bb3e..789bb462a 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -17,6 +17,7 @@ import { statDescriptor, } from './fs-descriptor.js'; import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; @@ -287,13 +288,13 @@ async function readVerifiedStoredPluginFiles( } if (sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); SET_ADD(paths, entry.path); - files[files.length] = { path: entry.path, data }; + appendIntrinsicArray(files, { path: entry.path, data }); } SET_ADD(paths, 'manifest.json'); await rejectExtras(directory, '', paths, drift); const frozen: Readonly<{ path: string; data: Buffer }>[] = []; for (let index = 0; index < files.length; index += 1) { - frozen[index] = OBJECT_FREEZE(files[index]!); + appendIntrinsicArray(frozen, OBJECT_FREEZE(files[index]!)); } return frozenHostedPromiseValue(frozen); } diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts index db9c6c597..9bc975849 100644 --- a/packages/sdk/tests/hosted-base-snapshot.test.ts +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -93,6 +93,36 @@ describe('hosted base private snapshot', () => { expect(after).not.toBe(before); }); + it('defines source entries without consulting inherited numeric setters', async () => { + const { project } = fixture(); + writeFileSync(join(project, 'helper.ts'), `export const identity = 'authentic';\n`); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + let poisonCalls = 0; + try { + Object.defineProperty(Array.prototype, '0', { + configurable: true, + set(this: unknown[], value: unknown) { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-')) { + poisonCalls += 1; + throw new Error('inherited array setter must not run'); + } + Object.defineProperty(this, '0', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + if (previous === undefined) delete (Array.prototype as unknown as Record)['0']; + else Object.defineProperty(Array.prototype, '0', previous); + } + expect(poisonCalls).toBe(0); + }); + it('uses the module-captured platform during source traversal', async () => { const { project } = fixture(); const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; From fcb886d416c30aabb85bb341eb5238f12d1230cb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:22:00 -0700 Subject: [PATCH 68/80] docs(evidence): record array-setter isolation gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index ee10bed56..dc7376dbf 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1626,3 +1626,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.yoEPLj/flows --help >/dev/null $ printf 'standalone-native-promise-pass %s\n' /tmp/flows-pr552-standalone.yoEPLj/flows standalone-native-promise-pass /tmp/flows-pr552-standalone.yoEPLj/flows + +$ git rev-parse HEAD +e209de64 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 226 passed (226) + Start at 17:20:40 + Duration 2.43s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Zkkb5P/flows +Bundled 782 modules in 34ms +authored-node.mjs 3.1 MB (entry point) +[49ms] bundle 715 modules +[86ms] compile /tmp/flows-pr552-standalone.Zkkb5P/flows + +$ /tmp/flows-pr552-standalone.Zkkb5P/flows --help >/dev/null +$ printf 'standalone-array-setter-pass %s\n' /tmp/flows-pr552-standalone.Zkkb5P/flows +standalone-array-setter-pass /tmp/flows-pr552-standalone.Zkkb5P/flows From 279c3ea88fa8328e58f7e3b87c1e53ce8dc01a4d Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:32:45 -0700 Subject: [PATCH 69/80] fix(sdk): snapshot stored plugin manifests Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/plugin-store.ts | 50 ++++++++++++------- .../sdk/tests/plugin-store-bounds.test.ts | 40 ++++++++++++++- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 789bb462a..1fa5f99f1 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -18,6 +18,7 @@ import { } from './fs-descriptor.js'; import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; import { appendIntrinsicArray } from './intrinsic-array.js'; +import { snapshotJsonValue, type JsonValue } from './json-value.js'; import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; @@ -254,10 +255,15 @@ async function readVerifiedStoredPluginFiles( } const raw = BUFFER_TO_STRING(manifest, 'utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); - let entries: { path: string; sha256: string; bytes: number }[]; + let entries: JsonValue[]; try { - entries = JSON_PARSE(raw); - if (!ARRAY_IS_ARRAY(entries)) throw new ERROR(); + const value = snapshotJsonValue(JSON_PARSE(raw), 'stored plugin manifest', { + maxBytes: MAX_PLUGIN_MANIFEST_BYTES, + maxDepth: 3, + maxNodes: 1 + MAX_PLUGIN_FILES * 4, + }); + if (!ARRAY_IS_ARRAY(value)) throw new ERROR(); + entries = value; } catch { return drift('manifest.json is not a manifest'); } @@ -268,27 +274,37 @@ async function readVerifiedStoredPluginFiles( for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]!; if ( - typeof entry?.path !== 'string' || - !safePath(entry.path) || - typeof entry.sha256 !== 'string' || - !REGEXP_TEST(/^[a-f0-9]{64}$/, entry.sha256) || - !NUMBER_IS_SAFE_INTEGER(entry.bytes) || - entry.bytes < 0 || - entry.bytes > MAX_PLUGIN_FILE_BYTES || - SET_HAS(paths, entry.path) + typeof entry !== 'object' || + entry === null || + ARRAY_IS_ARRAY(entry) ) return drift('manifest.json lists an invalid file'); - totalBytes += entry.bytes; + const path = entry.path; + const digest = entry.sha256; + const bytes = entry.bytes; + if ( + typeof path !== 'string' || + !safePath(path) || + typeof digest !== 'string' || + !REGEXP_TEST(/^[a-f0-9]{64}$/, digest) || + typeof bytes !== 'number' || + !NUMBER_IS_SAFE_INTEGER(bytes) || + bytes < 0 || + bytes > MAX_PLUGIN_FILE_BYTES || + SET_HAS(paths, path) + ) + return drift('manifest.json lists an invalid file'); + totalBytes += bytes; if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`); let data: Buffer; try { - data = await regularFile(directory, entry.path, MAX_PLUGIN_FILE_BYTES, entry.bytes, hooks); + data = await regularFile(directory, path, MAX_PLUGIN_FILE_BYTES, bytes, hooks); } catch (error) { - return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); + return drift(error instanceof PluginError ? error.message : `${path} is missing`); } - if (sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); - SET_ADD(paths, entry.path); - appendIntrinsicArray(files, { path: entry.path, data }); + if (sha256(data) !== digest) return drift(`${path} changed since installation`); + SET_ADD(paths, path); + appendIntrinsicArray(files, { path, data }); } SET_ADD(paths, 'manifest.json'); await rejectExtras(directory, '', paths, drift); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts index 7b2b9e0cc..c2188a08c 100644 --- a/packages/sdk/tests/plugin-store-bounds.test.ts +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readdirSync, rmSync, truncateSync, writeFileSync } from 'n import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { payloadManifest } from '../src/bundle.js'; +import { payloadManifest, sha256 } from '../src/bundle.js'; import { materializePlugin, readStoredPluginFiles, verifyStoredPlugin } from '../src/plugin-store.js'; const roots: string[] = []; @@ -160,6 +160,44 @@ describe('bounded plugin-store verification', () => { expect(poisonCalls).toBe(0); }); + it('snapshots manifest entries before reading their fields', async () => { + const stored = await fixture(); + const raw = '[{}]'; + writeFileSync(join(stored.directory, 'manifest.json'), raw); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'path'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'path', { + configurable: true, + enumerable: true, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('inherited manifest field must not run'); + } + return undefined; + }, + set(this: object, value: unknown) { + Object.defineProperty(this, 'path', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + await expect(verifyStoredPlugin(stored.directory, sha256(raw))).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('manifest.json lists an invalid file'), + }); + } finally { + if (previous === undefined) delete (Object.prototype as { path?: unknown }).path; + else Object.defineProperty(Object.prototype, 'path', previous); + } + expect(poisonCalls).toBe(0); + }); + it('uses the module-captured platform while opening stored files', async () => { const stored = await fixture(); const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; From e20742e478ae66bbfbabfc5829a3062b56d07d9c Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:33:21 -0700 Subject: [PATCH 70/80] docs(evidence): record manifest snapshot gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index dc7376dbf..4f89d46af 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1663,3 +1663,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.Zkkb5P/flows --help >/dev/null $ printf 'standalone-array-setter-pass %s\n' /tmp/flows-pr552-standalone.Zkkb5P/flows standalone-array-setter-pass /tmp/flows-pr552-standalone.Zkkb5P/flows + +$ git rev-parse HEAD +ce108703 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts tests/fs-descriptor.test.ts + + Test Files 10 passed (10) + Tests 227 passed (227) + Start at 17:32:35 + Duration 2.82s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.olHfJA/flows +Bundled 782 modules in 28ms +authored-node.mjs 3.1 MB (entry point) +[52ms] bundle 715 modules +[87ms] compile /tmp/flows-pr552-standalone.olHfJA/flows + +$ /tmp/flows-pr552-standalone.olHfJA/flows --help >/dev/null +$ printf 'standalone-manifest-snapshot-pass %s\n' /tmp/flows-pr552-standalone.olHfJA/flows +standalone-manifest-snapshot-pass /tmp/flows-pr552-standalone.olHfJA/flows From eb13c12cc98f407a02d7f63eb55e5a21e94e9b8e Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:34:31 -0700 Subject: [PATCH 71/80] docs(evidence): record rebased manifest gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 4f89d46af..f5cc268ce 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1700,3 +1700,33 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.olHfJA/flows --help >/dev/null $ printf 'standalone-manifest-snapshot-pass %s\n' /tmp/flows-pr552-standalone.olHfJA/flows standalone-manifest-snapshot-pass /tmp/flows-pr552-standalone.olHfJA/flows + +$ git rev-parse HEAD +edd83625 + +$ git merge-base HEAD origin/main +c2ac5be989ac595b4056c84a52ca1c819dffbdff + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts tests/fs-descriptor.test.ts + + Test Files 10 passed (10) + Tests 227 passed (227) + Start at 17:34:02 + Duration 2.74s From 1756c95c78d5a35cb0c43f58a051c3fdcfe1ab93 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 02:44:07 +0200 Subject: [PATCH 72/80] fix(sdk): preserve hosted dispatch matches Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-loader.ts | 3 +- .../tests/hosted-extension-routing.test.ts | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 8bf02fb49..1ed766264 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -8,6 +8,7 @@ import { findPluginProject } from './plugin-loader.js'; import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; @@ -203,7 +204,7 @@ export function extensionHandlerForHostedDispatch( if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) continue; if (subscription.action !== undefined && subscription.action !== identity.action) continue; - matches[matches.length] = { extension, handler }; + appendIntrinsicArray(matches, { extension, handler }); } } if (matches.length > 1) { diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index d9a5bfd2b..70a66e468 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -117,6 +117,45 @@ describe('hosted extension routing policy', () => { }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); }); + it('keeps the refusal match when Array.prototype has an inherited numeric setter', () => { + const body = async () => {}; + const handler = { trigger: github.pull_request('labeled'), body }; + const extension = { name: 'specific', handlers: [handler] }; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-setter', + }); + const defineProperty = Object.defineProperty; + const previous = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + let poisonCalls = 0; + let selected: ReturnType; + try { + defineProperty(Array.prototype, '0', { + configurable: true, + set(this: unknown[], value: unknown) { + const match = value as { extension?: unknown; handler?: unknown } | null; + if (match !== null && typeof match === 'object' + && match.extension === extension && match.handler === handler) { + poisonCalls += 1; + return; + } + defineProperty(this, '0', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + selected = extensionHandlerForHostedDispatch(dispatch, [extension]); + } finally { + if (previous === undefined) Reflect.deleteProperty(Array.prototype, '0'); + else defineProperty(Array.prototype, '0', previous); + } + expect(poisonCalls).toBe(0); + expect(selected?.extension).toBe(extension); + expect(selected?.handler).toBe(handler); + }); + it('parses and routes dispatch authority with captured intrinsics', () => { const body = async () => {}; const extension = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; From 0cba351d3025745b23c6a5ea0321fdb0e8b78497 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 23 Sep 2026 02:46:17 +0200 Subject: [PATCH 73/80] docs(evidence): record dispatch setter gate Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index f5cc268ce..190d8d204 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1730,3 +1730,30 @@ $ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted- Tests 227 passed (227) Start at 17:34:02 Duration 2.74s + +$ git rev-parse HEAD +37cdd771bb800d0b5cc111d66decb024e16cd202 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ ./node_modules/.bin/vitest run tests/hosted-extension-routing.test.ts + + Test Files 1 passed (1) + Tests 6 passed (6) + Start at 02:43:18 + Duration 4.45s From 21dc984996cd2f6b9f8a273aed2d142227f8b55e Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:45:55 -0700 Subject: [PATCH 74/80] fix(sdk): bound hosted surface reads Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-loader.ts | 1 + packages/sdk/src/hosted-extension-sandbox.ts | 86 +++++++++++++++---- .../tests/hosted-extension-isolation.test.ts | 25 ++++++ 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 1ed766264..882938f6d 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -5,6 +5,7 @@ import { sha256 } from './bundle.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions, type RuntimeVersions } from './flow-extension-compat.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; import { findPluginProject } from './plugin-loader.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 15bd751cb..3a41361bb 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -1,13 +1,20 @@ import { createRequire } from 'node:module'; -import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { constants, existsSync, lstatSync, realpathSync } from 'node:fs'; import { dirname, join, parse, resolve } from 'node:path'; import { ChildProcess, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { Writable, type Readable } from 'node:stream'; import { payloadManifest, sha256 } from './bundle.js'; -import { descriptorIsFile } from './fs-descriptor.js'; +import { + closeDescriptor, + descriptorIsFile, + openDescriptor, + readDescriptor, + statDescriptor, +} from './fs-descriptor.js'; +import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; -import { assertHostedPromiseSafety } from './hosted-promise-safety.js'; +import { assertHostedPromiseSafety, hostedPromiseValue } from './hosted-promise-safety.js'; import { appendIntrinsicArray } from './intrinsic-array.js'; import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; import { @@ -20,7 +27,6 @@ const HOSTED_WRITE = 'cloud:babysitter-turn'; const CREATE_REQUIRE = createRequire; const EXISTS_SYNC = existsSync; const LSTAT_SYNC = lstatSync; -const READ_FILE_SYNC = readFileSync; const REALPATH_SYNC = realpathSync; const PATH_DIRNAME = dirname; const PATH_JOIN = join; @@ -37,7 +43,13 @@ const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( child: ChildProcess, signal?: NodeJS.Signals | number, ) => boolean; +const ARRAY_IS_ARRAY = Array.isArray; const BUFFER_FROM = Buffer.from; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, encoding: BufferEncoding, +) => string; +const BIG_INT = BigInt; const JSON_PARSE = JSON.parse; const OBJECT_ENTRIES = Object.entries; const OBJECT_FREEZE = Object.freeze; @@ -64,6 +76,9 @@ const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as stream: Writable, chunk: string | Uint8Array, ) => boolean; const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_SURFACE_PACKAGE_BYTES = 64 * 1024; +const MAX_SURFACE_RUNTIME_BYTES = 512 * 1024; +const SURFACE_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); // These hard limits are inherited across prlimit -> bubblewrap -> Node and its // descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 // and Wasm reservations; RLIMIT_DATA is the tighter bound on anonymous/native @@ -127,7 +142,7 @@ export async function runHostedExtensionSandbox( if (!STRING_STARTS_WITH(entryPath, `${PATH_RESOLVE(options.artifactDirectory)}/`)) { throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); } - const surfaceRoot = resolveSurfaceRoot( + const surfaceRoot = await resolveSurfaceRoot( options.surfaceVersion, ownOption(options, 'surfaceRoot'), ); @@ -147,7 +162,7 @@ export async function runHostedExtensionSandbox( destination: '/runtime/runner.mjs', bytes: BUFFER_FROM(HOSTED_EXTENSION_SANDBOX_SOURCE), }]; - const surfaceFiles = readSurfaceFiles(surfaceRoot); + const surfaceFiles = await readSurfaceFiles(surfaceRoot); for (let index = 0; index < surfaceFiles.length; index += 1) { appendIntrinsicArray(dataFiles, surfaceFiles[index]!); } @@ -201,8 +216,8 @@ export async function runHostedExtensionSandbox( } } -function resolveSurfaceRoot(expectedVersion: string, override?: string): string { - if (override !== undefined) return checkedSurfaceRoot(override, expectedVersion); +async function resolveSurfaceRoot(expectedVersion: string, override?: string): Promise { + if (override !== undefined) return await checkedSurfaceRoot(override, expectedVersion); let resolved: string; try { resolved = REALPATH_SYNC(CREATE_REQUIRE(import.meta.url).resolve('@relayflows/surface')); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } @@ -212,7 +227,7 @@ function resolveSurfaceRoot(expectedVersion: string, override?: string): string const packageJson = PATH_JOIN(directory, 'package.json'); if (EXISTS_SYNC(packageJson)) { try { - const manifest = JSON_PARSE(READ_FILE_SYNC(packageJson, 'utf8')) as { name?: unknown; version?: unknown }; + const manifest = surfacePackageManifest(await readBoundedSurfaceFile(packageJson, MAX_SURFACE_PACKAGE_BYTES)); if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { return REALPATH_SYNC(directory); } @@ -223,20 +238,20 @@ function resolveSurfaceRoot(expectedVersion: string, override?: string): string return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } -function checkedSurfaceRoot(root: string, expectedVersion: string): string { +async function checkedSurfaceRoot(root: string, expectedVersion: string): Promise { let real: string; try { real = REALPATH_SYNC(root); } catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } try { - const manifest = JSON_PARSE(READ_FILE_SYNC(PATH_JOIN(real, 'package.json'), 'utf8')) as { - name?: unknown; version?: unknown; - }; + const manifest = surfacePackageManifest( + await readBoundedSurfaceFile(PATH_JOIN(real, 'package.json'), MAX_SURFACE_PACKAGE_BYTES), + ); if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) return real; } catch { /* fall through */ } return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } -function readSurfaceFiles(surfaceRoot: string): SandboxDataFile[] { +async function readSurfaceFiles(surfaceRoot: string): Promise { const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); const files: SandboxDataFile[] = [ { destination: '/extension/node_modules/@relayflows/surface/package.json', bytes: BUFFER_FROM(SURFACE_PACKAGE_JSON) }, @@ -247,7 +262,7 @@ function readSurfaceFiles(surfaceRoot: string): SandboxDataFile[] { const file = entries[index]![0]; const expected = entries[index]![1]; let bytes: Buffer; - try { bytes = READ_FILE_SYNC(PATH_JOIN(surfaceRoot, 'dist', file)); } + try { bytes = await readBoundedSurfaceFile(PATH_JOIN(surfaceRoot, 'dist', file), MAX_SURFACE_RUNTIME_BYTES); } catch { return unsupported(`hosted extension cannot read pinned Surface runtime ${file}`); } if (sha256(bytes) !== expected) { return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); @@ -260,6 +275,47 @@ function readSurfaceFiles(surfaceRoot: string): SandboxDataFile[] { return files; } +function surfacePackageManifest(bytes: Buffer): { readonly name?: unknown; readonly version?: unknown } { + const value = snapshotJsonValue(JSON_PARSE(BUFFER_TO_STRING(bytes, 'utf8')), 'Surface package manifest', { + maxBytes: MAX_SURFACE_PACKAGE_BYTES, + maxDepth: 3, + maxNodes: 64, + }); + if (typeof value !== 'object' || value === null || ARRAY_IS_ARRAY(value)) { + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); + } + return value as { readonly name?: unknown; readonly version?: unknown }; +} + +async function readBoundedSurfaceFile(path: string, maxBytes: number): Promise { + const descriptor = await openDescriptor(path, SURFACE_READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(maxBytes)) { + return unsupported(`hosted extension cannot read bounded Surface file ${path}`); + } + const size = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(size); + let offset = 0; + while (offset < size) { + const bytesRead = await readDescriptor(descriptor, bytes, offset, size - offset, offset); + if (bytesRead === 0) return unsupported(`hosted extension Surface file ${path} changed while reading`); + offset += bytesRead; + } + const extra = BUFFER_ALLOC_UNSAFE(1); + if ((await readDescriptor(descriptor, extra, 0, 1, size)) !== 0) { + return unsupported(`hosted extension Surface file ${path} changed while reading`); + } + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + return unsupported(`hosted extension Surface file ${path} changed while reading`); + } + return hostedPromiseValue(bytes); + } finally { + await closeDescriptor(descriptor); + } +} + /** @internal Pure construction seam for hostile-intrinsic regressions. */ export function sandboxArguments(input: { node: string; dataDestinations: readonly string[]; diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index b5eacf963..5e29ca2e1 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, rmSync, + truncateSync, writeFileSync, } from 'node:fs'; import { createServer } from 'node:http'; @@ -478,6 +479,30 @@ process.exit(child.status ?? 1); expect(calls).toBe(0); }); + it('refuses oversized Surface files through the bounded descriptor reader', async () => { + const surfaceRoot = surfaceFixture(); + truncateSync(join(surfaceRoot, 'dist/flow.js'), 512 * 1024 + 1); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-surface-size', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-surface-size'), + surfaceRoot, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('cannot read pinned Surface runtime flow.js'), + }); + expect(calls).toBe(0); + }); + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { const source = ` import { flow, github } from '@relayflows/surface'; From 56955f41ebfbade9a9fde9b2a6f08b0180c1c086 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:46:24 -0700 Subject: [PATCH 75/80] docs(evidence): record bounded surface gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 190d8d204..61e5c600d 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1757,3 +1757,42 @@ $ ./node_modules/.bin/vitest run tests/hosted-extension-routing.test.ts Tests 6 passed (6) Start at 02:43:18 Duration 4.45s + +$ git rev-parse HEAD +f4f9ff42 + +$ git diff origin/main..HEAD -- .github/workflows/cloud-runtime-artifact.yml + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 229 passed (229) + Start at 17:45:33 + Duration 2.60s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Txn3LL/flows +Bundled 782 modules in 29ms +authored-node.mjs 3.1 MB (entry point) +[51ms] bundle 715 modules +[100ms] compile /tmp/flows-pr552-standalone.Txn3LL/flows + +$ /tmp/flows-pr552-standalone.Txn3LL/flows --help >/dev/null +$ printf 'standalone-bounded-surface-pass %s\n' /tmp/flows-pr552-standalone.Txn3LL/flows +standalone-bounded-surface-pass /tmp/flows-pr552-standalone.Txn3LL/flows From 4ced3d5f094501ea2a9fc6869048c43517249a74 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:49:36 -0700 Subject: [PATCH 76/80] fix(sdk): deduplicate hardened dispatch import Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/flow-extension-loader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index 882938f6d..af02dd919 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -9,7 +9,6 @@ import { appendIntrinsicArray } from './intrinsic-array.js'; import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; -import { appendIntrinsicArray } from './intrinsic-array.js'; const JSON_PARSE = JSON.parse; const ARRAY_IS_ARRAY = Array.isArray; From 1c595c0d2969c949eac456db0d5863583111bffb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 17:50:06 -0700 Subject: [PATCH 77/80] docs(evidence): record release-rebased surface gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 61e5c600d..25313821b 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1796,3 +1796,45 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.Txn3LL/flows --help >/dev/null $ printf 'standalone-bounded-surface-pass %s\n' /tmp/flows-pr552-standalone.Txn3LL/flows standalone-bounded-surface-pass /tmp/flows-pr552-standalone.Txn3LL/flows + +$ git rev-parse HEAD +b7dc537b + +$ git merge-base HEAD origin/main +a963b25e2a030e337ae3c0382c1d029861070d8e + +$ git diff origin/main..HEAD -- .github/workflows/cloud-runtime-artifact.yml + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 229 passed (229) + Start at 17:49:24 + Duration 2.27s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Gv1Jjf/flows +Bundled 782 modules in 32ms +authored-node.mjs 3.1 MB (entry point) +[51ms] bundle 715 modules +[102ms] compile /tmp/flows-pr552-standalone.Gv1Jjf/flows + +$ /tmp/flows-pr552-standalone.Gv1Jjf/flows --help >/dev/null +$ printf 'standalone-rebased-surface-pass %s\n' /tmp/flows-pr552-standalone.Gv1Jjf/flows +standalone-rebased-surface-pass /tmp/flows-pr552-standalone.Gv1Jjf/flows From 6987e2e34fdb18981697886e2a104f896e33ae48 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 18:00:38 -0700 Subject: [PATCH 78/80] fix(sdk): shield verified surface files Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- packages/sdk/src/hosted-extension-sandbox.ts | 10 ++++-- .../tests/hosted-extension-isolation.test.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 3a41361bb..40cd192ff 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -14,7 +14,11 @@ import { } from './fs-descriptor.js'; import { snapshotJsonValue } from './json-value.js'; import { PluginError } from './plugin-manifest.js'; -import { assertHostedPromiseSafety, hostedPromiseValue } from './hosted-promise-safety.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, + hostedPromiseValue, +} from './hosted-promise-safety.js'; import { appendIntrinsicArray } from './intrinsic-array.js'; import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; import { @@ -251,7 +255,7 @@ async function checkedSurfaceRoot(root: string, expectedVersion: string): Promis return unsupported('hosted extension resolved an invalid @relayflows/surface package'); } -async function readSurfaceFiles(surfaceRoot: string): Promise { +async function readSurfaceFiles(surfaceRoot: string): Promise { const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); const files: SandboxDataFile[] = [ { destination: '/extension/node_modules/@relayflows/surface/package.json', bytes: BUFFER_FROM(SURFACE_PACKAGE_JSON) }, @@ -272,7 +276,7 @@ async function readSurfaceFiles(surfaceRoot: string): Promise bytes, }); } - return files; + return frozenHostedPromiseValue(files); } function surfacePackageManifest(bytes: Buffer): { readonly name?: unknown; readonly version?: unknown } { diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts index 5e29ca2e1..d065ae52f 100644 --- a/packages/sdk/tests/hosted-extension-isolation.test.ts +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -503,6 +503,40 @@ process.exit(child.status ?? 1); expect(calls).toBe(0); }); + it('shields verified Surface files before async settlement', async () => { + const surfaceRoot = surfaceFixture(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-surface-then', + }); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'then'); + let poisonCalls = 0; + try { + Object.defineProperty(Array.prototype, 'then', { + configurable: true, + get(this: unknown[]) { + const first = this[0] as { destination?: unknown } | undefined; + if (this.length === 9 + && first?.destination === '/extension/node_modules/@relayflows/surface/package.json') { + poisonCalls += 1; + } + return undefined; + }, + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-surface-then'), + surfaceRoot, + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Array.prototype as { then?: unknown }).then; + else Object.defineProperty(Array.prototype, 'then', previous); + } + expect(poisonCalls).toBe(0); + }); + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { const source = ` import { flow, github } from '@relayflows/surface'; From 6a6299c235ab719a03dc0d51ac0e6e31b1c33e3d Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 18:01:15 -0700 Subject: [PATCH 79/80] docs(evidence): record surface promise gate Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 25313821b..98b96e6c9 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1838,3 +1838,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.Gv1Jjf/flows --help >/dev/null $ printf 'standalone-rebased-surface-pass %s\n' /tmp/flows-pr552-standalone.Gv1Jjf/flows standalone-rebased-surface-pass /tmp/flows-pr552-standalone.Gv1Jjf/flows + +$ git rev-parse HEAD +c0f9d7cf + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 230 passed (230) + Start at 18:00:29 + Duration 2.50s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.0BB8Xx/flows +Bundled 782 modules in 26ms +authored-node.mjs 3.1 MB (entry point) +[57ms] bundle 715 modules +[99ms] compile /tmp/flows-pr552-standalone.0BB8Xx/flows + +$ /tmp/flows-pr552-standalone.0BB8Xx/flows --help >/dev/null +$ printf 'standalone-surface-promise-pass %s\n' /tmp/flows-pr552-standalone.0BB8Xx/flows +standalone-surface-promise-pass /tmp/flows-pr552-standalone.0BB8Xx/flows From d401cb8c6e6b59014449fa9503772fe5a1f830a8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Tue, 22 Sep 2026 18:22:31 -0700 Subject: [PATCH 80/80] fix(sdk): pin hosted Node executable bytes Session-Id: 01a0c8b1-3f30-7d13-a8b1-4c0964bae5d2 Session-Id: 01a0cb2d-1280-7f83-bd35-6689232b63ec --- .../verification.txt | 37 ++++++ packages/sdk/src/hosted-extension-sandbox.ts | 107 ++++++++++++++++-- .../tests/hosted-extension-routing.test.ts | 37 +++++- 3 files changed, 166 insertions(+), 15 deletions(-) diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt index 98b96e6c9..2a4d2edd6 100644 --- a/evidence/babysitter-gate8-isolation/verification.txt +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -1875,3 +1875,40 @@ authored-node.mjs 3.1 MB (entry point) $ /tmp/flows-pr552-standalone.0BB8Xx/flows --help >/dev/null $ printf 'standalone-surface-promise-pass %s\n' /tmp/flows-pr552-standalone.0BB8Xx/flows standalone-surface-promise-pass /tmp/flows-pr552-standalone.0BB8Xx/flows + +$ git rev-parse HEAD +4858f6ce46ad14e3e52a6f3fdeb602b2ef64a805 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 231 passed (231) + Start at 18:20:18 + Duration 5.58s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-node-pin.YK9Mv1/flows +Bundled 782 modules in 38ms +authored-node.mjs 3.1 MB (entry point) +[59ms] bundle 715 modules +[112ms] compile /tmp/flows-pr552-node-pin.YK9Mv1/flows + +$ /tmp/flows-pr552-node-pin.YK9Mv1/flows --help >/dev/null +$ printf 'standalone-node-pin-pass %s\n' /tmp/flows-pr552-node-pin.YK9Mv1/flows +standalone-node-pin-pass /tmp/flows-pr552-node-pin.YK9Mv1/flows diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts index 40cd192ff..6af09a00c 100644 --- a/packages/sdk/src/hosted-extension-sandbox.ts +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -1,5 +1,15 @@ import { createRequire } from 'node:module'; -import { constants, existsSync, lstatSync, realpathSync } from 'node:fs'; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, + type BigIntStats, +} from 'node:fs'; import { dirname, join, parse, resolve } from 'node:path'; import { ChildProcess, spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -29,8 +39,12 @@ import { readStoredPluginFiles } from './plugin-store.js'; const HOSTED_WRITE = 'cloud:babysitter-turn'; const CREATE_REQUIRE = createRequire; +const CLOSE_SYNC = closeSync; const EXISTS_SYNC = existsSync; +const FSTAT_SYNC = fstatSync; const LSTAT_SYNC = lstatSync; +const OPEN_SYNC = openSync; +const READ_SYNC = readSync; const REALPATH_SYNC = realpathSync; const PATH_DIRNAME = dirname; const PATH_JOIN = join; @@ -82,7 +96,9 @@ const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as const DEFAULT_TIMEOUT_MS = 10_000; const MAX_SURFACE_PACKAGE_BYTES = 64 * 1024; const MAX_SURFACE_RUNTIME_BYTES = 512 * 1024; +const MAX_NODE_EXECUTABLE_BYTES = 512 * 1024 * 1024; const SURFACE_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); +const NODE_EXECUTABLE = captureNodeExecutable(); // These hard limits are inherited across prlimit -> bubblewrap -> Node and its // descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 // and Wasm reservations; RLIMIT_DATA is the tighter bound on anonymous/native @@ -108,6 +124,12 @@ interface SandboxDataFile { readonly bytes: Uint8Array; } +export interface CapturedExecutable { + readonly descriptor: number; + readonly sha256: string; + readonly stat: BigIntStats; +} + export interface RunHostedExtensionSandboxOptions { readonly artifactDirectory: string; readonly artifactDigest: string; @@ -137,7 +159,13 @@ export async function runHostedExtensionSandbox( } const bwrap = executable(ownOption(options, 'bubblewrapPath') ?? '/usr/bin/bwrap', 'bubblewrap'); const prlimit = executable(ownOption(options, 'prlimitPath') ?? '/usr/bin/prlimit', 'prlimit'); - const node = executable(nodeOverride ?? PROCESS_EXEC_PATH, 'Node'); + const nodeCapture = nodeOverride === undefined + ? NODE_EXECUTABLE + : captureExecutable(nodeOverride, 'Node'); + if (nodeCapture === undefined) return unsupported('Node is unavailable'); + let nodeBytes: Buffer; + try { nodeBytes = readCapturedExecutable(nodeCapture, 'Node'); } + finally { if (nodeOverride !== undefined) CLOSE_SYNC(nodeCapture.descriptor); } const timeoutMs = ownOption(options, 'timeoutMs') ?? DEFAULT_TIMEOUT_MS; if (!NUMBER_IS_SAFE_INTEGER(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); @@ -162,10 +190,10 @@ export async function runHostedExtensionSandbox( 'Hosted extension changed while its isolated snapshot was created.', ); } - const dataFiles: SandboxDataFile[] = [{ - destination: '/runtime/runner.mjs', - bytes: BUFFER_FROM(HOSTED_EXTENSION_SANDBOX_SOURCE), - }]; + const dataFiles: SandboxDataFile[] = [ + { destination: '/runtime/node', bytes: nodeBytes }, + { destination: '/runtime/runner.mjs', bytes: BUFFER_FROM(HOSTED_EXTENSION_SANDBOX_SOURCE) }, + ]; const surfaceFiles = await readSurfaceFiles(surfaceRoot); for (let index = 0; index < surfaceFiles.length; index += 1) { appendIntrinsicArray(dataFiles, surfaceFiles[index]!); @@ -181,7 +209,7 @@ export async function runHostedExtensionSandbox( for (let index = 0; index < dataFiles.length; index += 1) { appendIntrinsicArray(destinations, dataFiles[index]!.destination); } - const args = sandboxArguments({ node, dataDestinations: destinations }); + const args = sandboxArguments({ dataDestinations: destinations }); await ownOption<() => Promise>(options, 'beforeLaunch')?.(); const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; for (let index = 0; index < args.length; index += 1) appendIntrinsicArray(commandArgs, args[index]!); @@ -322,7 +350,7 @@ async function readBoundedSurfaceFile(path: string, maxBytes: number): Promise BIG_INT(MAX_NODE_EXECUTABLE_BYTES)) { + return unsupported(`${name} is not a bounded regular file`); + } + const bytes = readExecutableDescriptor(descriptor, NUMBER(stat.size), name); + const after = FSTAT_SYNC(descriptor, { bigint: true }); + if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size + || after.mtimeNs !== stat.mtimeNs || after.ctimeNs !== stat.ctimeNs) { + return unsupported(`${name} changed while being captured`); + } + return OBJECT_FREEZE({ descriptor, sha256: sha256(bytes), stat }); + } catch (error) { + CLOSE_SYNC(descriptor); + throw error; + } +} + +/** @internal Reads the inode captured before authored code could replace its path. */ +export function readCapturedExecutable(capture: CapturedExecutable, name: string): Buffer { + const size = NUMBER(capture.stat.size); + const bytes = readExecutableDescriptor(capture.descriptor, size, name); + const after = FSTAT_SYNC(capture.descriptor, { bigint: true }); + if (after.dev !== capture.stat.dev || after.ino !== capture.stat.ino + || after.size !== capture.stat.size || sha256(bytes) !== capture.sha256) { + return unsupported(`${name} changed while reading`); + } + return bytes; +} + +function readExecutableDescriptor(descriptor: number, size: number, name: string): Buffer { + const bytes = BUFFER_ALLOC_UNSAFE(size); + let offset = 0; + while (offset < size) { + const bytesRead = READ_SYNC(descriptor, bytes, offset, size - offset, offset); + if (bytesRead === 0) return unsupported(`${name} changed while reading`); + offset += bytesRead; + } + return bytes; +} + function executable(path: string, name: string): string { let real: string; diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts index 70a66e468..4d46b5a1a 100644 --- a/packages/sdk/tests/hosted-extension-routing.test.ts +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { closeSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { github } from '@relayflows/surface'; import { extensionHandlerForHostedDispatch, @@ -6,7 +9,12 @@ import { } from '../src/flow-extension-loader.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; -import { sandboxArguments, supportsHostedSandboxFlags } from '../src/hosted-extension-sandbox.js'; +import { + captureExecutable, + readCapturedExecutable, + sandboxArguments, + supportsHostedSandboxFlags, +} from '../src/hosted-extension-sandbox.js'; describe('hosted extension routing policy', () => { it('accepts only Node releases that implement every sandbox flag', () => { @@ -44,7 +52,7 @@ describe('hosted extension routing policy', () => { 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', 'triggers.js', 'triggers/github.js', ]; - const dataDestinations = ['/runtime/runner.mjs', '/extension/src/babysitter.flow.ts']; + const dataDestinations = ['/runtime/node', '/runtime/runner.mjs', '/extension/src/babysitter.flow.ts']; for (let index = 0; index < surfaceFiles.length; index += 1) { dataDestinations[dataDestinations.length] = `/extension/node_modules/@relayflows/surface/dist/${surfaceFiles[index]!}`; } @@ -71,9 +79,7 @@ describe('hosted extension routing policy', () => { } return Reflect.apply(push, this, values); } as typeof Array.prototype.push; - args = sandboxArguments({ - node: '/trusted/node', dataDestinations, - }); + args = sandboxArguments({ dataDestinations }); } finally { Array.prototype.flatMap = flatMap; Array.prototype.push = push; @@ -85,6 +91,27 @@ describe('hosted extension routing policy', () => { } expect(args).toContain('--ro-bind-data'); expect(args).not.toContain('/trusted/extension'); + const nodeIndex = args.indexOf('/runtime/node'); + expect(args.slice(nodeIndex - 4, nodeIndex + 1)).toEqual([ + '--perms', '0500', '--ro-bind-data', '4', '/runtime/node', + ]); + }); + + it('reads the executable inode captured before an atomic path replacement', () => { + const root = mkdtempSync(join(tmpdir(), 'hosted-node-pin-')); + const executable = join(root, 'node'); + const replacement = join(root, 'replacement'); + writeFileSync(executable, 'reviewed-node-bytes'); + writeFileSync(replacement, 'attacker-node-bytes'); + const capture = captureExecutable(executable, 'test Node'); + try { + renameSync(replacement, executable); + expect(readCapturedExecutable(capture, 'test Node').toString('utf8')) + .toBe('reviewed-node-bytes'); + } finally { + closeSync(capture.descriptor); + rmSync(root, { recursive: true, force: true }); + } }); it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => {