diff --git a/docs/CLOUD.md b/docs/CLOUD.md
index 1fc6bc839..5a74ef5b4 100644
--- a/docs/CLOUD.md
+++ b/docs/CLOUD.md
@@ -340,10 +340,17 @@ SDK; the same function reads a compiled YAML spec, where a helper step such as
(`.on(github.issues())`), the `--on` sources and the deploy target (every
launched run lands in `--repo`, so GitHub is always required), the `cli:` of
each `f.agent`/`f.llm` call in the default body (else the nearest `flows.json`
-`cli`, else `claude`), and `tools.mcp`. Handler bodies are not scanned: hosted
-dispatch runs the default body (flows #301), so only a handler's trigger is a
-requirement. The deploy body carries the same list as `requirements` for
-Cloud to cross-check, and a declared harness Cloud cannot run yet (`gemini`)
+`cli`, else `claude`), and `tools.mcp`. Handler bodies are not statically
+scanned for requirements. Authored input never selects an extension handler:
+only server-authenticated integration delivery metadata passed out of band may
+match one. Direct runs and authenticated deliveries that match no extension
+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
+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.
Before `flows deploy`, `flows schedule` and `flows run --cloud` submit anything,
diff --git a/packages/sdk/src/authored-flow-executor.ts b/packages/sdk/src/authored-flow-executor.ts
index bbd66d05e..25b979b6b 100644
--- a/packages/sdk/src/authored-flow-executor.ts
+++ b/packages/sdk/src/authored-flow-executor.ts
@@ -44,8 +44,14 @@ import {
} from './authored-flow-operation.js';
import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js';
import { JournalClient } from './journal-client.js';
+import { PluginError } from './plugin-manifest.js';
import { createHookEvaluator } from './authored-hooks.js';
-import { probeFlowExtension, type LoadedFlowExtension } from './flow-extension-loader.js';
+import {
+ extensionHandlerForHostedDispatch,
+ probeFlowExtension,
+ type HostedExtensionDispatch,
+ type LoadedFlowExtension,
+} from './flow-extension-loader.js';
import type {
CompletionReason as ProtocolCompletionReason,
RunCompletionReason as ProtocolRunCompletionReason,
@@ -152,6 +158,11 @@ export interface ExecuteAuthoredFlowOptions {
readonly rootRunId?: string;
/** Installed flow-extension plugins, in lock order, so `f.hook` can AND-compose them. */
readonly extensions?: readonly LoadedFlowExtension[];
+ /**
+ * Server-authenticated integration delivery authority. Never derive this
+ * from authored input: direct `/workflows/run` callers control that JSON.
+ */
+ readonly extensionDispatch?: HostedExtensionDispatch;
}
export async function executeAuthoredFlow(
@@ -175,6 +186,7 @@ export async function executeAuthoredFlow(
...(options.onWait !== undefined ? { onWait: options.onWait } : {}),
};
const definition = getDefinition(handle);
+ const hostedHandler = extensionHandlerForHostedDispatch(options.extensionDispatch, options.extensions ?? []);
const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory' && key !== 'version' && key !== 'hooks');
if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['mcp', ...helperProviders.map(p => p.namespace)].includes(key))) headerFields.push('tools');
if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile');
@@ -195,6 +207,18 @@ export async function executeAuthoredFlow(
for (const extension of options.extensions ?? []) {
if (extension.manifest !== undefined) await probeFlowExtension(extension.manifest);
}
+ if (hostedHandler !== undefined) {
+ // A schema-2 entry is ordinary authored JavaScript. Passing it the base
+ // context would not constrain direct Node access, f.run, helpers, MCP or
+ // agent harnesses to manifest.permissions; those declarations are still
+ // explicitly unenforced by gate 8 / #442. Refuse before either body runs.
+ // A later isolated runtime may replace this gate only when it can prove
+ // the manifest boundary, not merely proxy selected Ctx properties.
+ throw new PluginError(
+ 'plugin_unsupported',
+ `${hostedHandler.extension.name}: hosted extension handlers require enforced manifest permission isolation (gate 8 / #442).`,
+ );
+ }
const budget = new AuthoredBudget(definition.header.budget);
if (definition.header.memory?.agent === true) {
diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts
index 14be52141..be1d90110 100644
--- a/packages/sdk/src/flow-extension-loader.ts
+++ b/packages/sdk/src/flow-extension-loader.ts
@@ -99,6 +99,93 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str
return action === undefined ? { provider, event: type } : { provider, event: type, action };
}
+/**
+ * Server-authenticated integration delivery metadata. This is executor
+ * authority, not authored input: a caller must derive it from its verified
+ * delivery record and pass it out of band. User-controlled `input.event`
+ * objects never become this value.
+ */
+const HOSTED_EXTENSION_DISPATCH_AUTHORITY = Symbol('hosted-extension-dispatch-authority');
+
+export type HostedExtensionDispatch = {
+ readonly [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true;
+ readonly provenance: 'integration-watch';
+ readonly provider: string;
+ readonly eventType: string;
+ readonly deliveryId: string;
+};
+
+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}$/;
+
+function hostedEventIdentity(dispatch: unknown): HostedEventIdentity {
+ if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(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 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.`);
+ }
+ return parts.length === 1
+ ? { provider, event: parts[0]! }
+ : { provider, event: parts[0]!, action: parts[1]! };
+}
+
+/**
+ * Brand metadata only after the host has authenticated the integration
+ * delivery. The symbol is deliberately not serializable, so copying a direct
+ * run's JSON into executor options cannot mint dispatch authority.
+ */
+export function hostedExtensionDispatchFromVerifiedDelivery(
+ delivery: Omit,
+): HostedExtensionDispatch {
+ const dispatch = Object.freeze({
+ [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const,
+ provenance: 'integration-watch' as const,
+ ...delivery,
+ });
+ hostedEventIdentity(dispatch);
+ return dispatch;
+}
+
+/**
+ * Resolve a server-authenticated delivery to one extension handler. Authored
+ * input is deliberately absent from this API: direct runs may contain any
+ * JSON shape and cannot opt themselves into extension execution. Overlapping
+ * subscriptions fail closed, including a generic event handler overlapping
+ * an action-specific handler.
+ */
+export function extensionHandlerForHostedDispatch(
+ dispatch: unknown,
+ extensions: readonly Pick[],
+): { 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 }];
+ }));
+ if (matches.length > 1) {
+ 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(', ')}).`,
+ );
+ }
+ return matches[0];
+}
+
function assertDeclaredSubscription(name: string, manifest: FlowExtensionManifest, handler: TriggerHandler, index: number): void {
const subscription = subscriptionOf(handler);
if (handler.trigger.kind === 'schedule') unsupported(name, `handler ${index} (a schedule trigger)`);
diff --git a/packages/sdk/src/plugin-manifest.ts b/packages/sdk/src/plugin-manifest.ts
index fb4791d79..54dcd5be1 100644
--- a/packages/sdk/src/plugin-manifest.ts
+++ b/packages/sdk/src/plugin-manifest.ts
@@ -7,6 +7,7 @@ export const PLUGIN_FAILURE_KINDS = [
'plugin_unsupported', 'plugin_credential_missing', 'plugin_server_unreachable',
// schema 2 flow extensions (see flow-extension-manifest.ts, plugin-source.ts, plugin-github.ts)
'plugin_kind_invalid', 'plugin_incompatible', 'plugin_event_unroutable', 'plugin_source_invalid',
+ 'plugin_event_ambiguous',
'plugin_source_unresolved', 'plugin_fetch_failed', 'plugin_path_invalid', 'plugin_too_large',
'plugin_source_drift', 'plugin_lock_invalid',
] as const;
diff --git a/packages/sdk/tests/authored-flow.test.ts b/packages/sdk/tests/authored-flow.test.ts
index 32da08917..7f1baca1a 100644
--- a/packages/sdk/tests/authored-flow.test.ts
+++ b/packages/sdk/tests/authored-flow.test.ts
@@ -1,9 +1,15 @@
import { rmSync } from 'node:fs';
import type { Server } from 'node:net';
-import { flow, type Ctx, type FlowHeader } from '@relayflows/surface';
+import { flow, github, type Ctx, type FlowHeader } from '@relayflows/surface';
+import { getFlowDefinition } from '@relayflows/surface/runtime';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { executeAuthoredFlow } from '../src/authored-flow-executor.js';
+import type { FlowExtensionManifest } from '../src/flow-extension-manifest.js';
import { JournalClient } from '../src/journal-client.js';
+import {
+ hostedExtensionDispatchFromVerifiedDelivery,
+ type LoadedFlowExtension,
+} from '../src/flow-extension-loader.js';
import {
kernelDialectError,
sendOk,
@@ -562,6 +568,111 @@ describe('authored flow journal executor', () => {
}
});
+ it('does not let direct-run input impersonate an integration delivery', async () => {
+ const base = flow<{ event: { provider: string; eventType: string } }>('software-factory', async (f) => {
+ await f.run('printf base-body');
+ f.done('success');
+ });
+ let extensionRuns = 0;
+ const extensionHandle = flow('babysitter', async (f) => f.done('declined'))
+ .on(github.pull_request('labeled'), async (f) => {
+ extensionRuns += 1;
+ await f.run('printf extension-body');
+ f.done('success');
+ });
+ const extension = loadedExtension('babysitter', extensionHandle);
+ const client = await connectedClient('authored-extension-handler-test');
+ const before = startedSpecs.length;
+ try {
+ await executeAuthoredFlow(base, client, {
+ event: { provider: 'github', eventType: 'pull_request.labeled' },
+ }, { extensions: [extension] });
+ } finally {
+ client.close();
+ }
+ expect(commandsSince(before)).toEqual(['printf base-body', ':']);
+ expect(extensionRuns).toBe(0);
+ });
+
+ it('refuses an authenticated matching handler before base or extension authority can run', async () => {
+ let baseRuns = 0;
+ let extensionRuns = 0;
+ const base = flow('software-factory', async (f) => {
+ baseRuns += 1;
+ await f.run('printf base-body');
+ f.done('success');
+ });
+ const extension = loadedExtension('babysitter', flow('babysitter', async (f) => f.done('declined'))
+ .on(github.pull_request('labeled'), async (f) => {
+ extensionRuns += 1;
+ await f.run('printf extension-body');
+ f.done('success');
+ }));
+
+ await expect(executeAuthoredFlow(base, new JournalClient('/unused'), undefined, {
+ extensions: [extension],
+ extensionDispatch: integrationDispatch('pull_request.labeled'),
+ })).rejects.toMatchObject({ code: 'plugin_unsupported' });
+ expect(baseRuns).toBe(0);
+ expect(extensionRuns).toBe(0);
+ });
+
+ it('fails closed when one event matches identical extension subscriptions', async () => {
+ const extension = (name: string) => loadedExtension(name,
+ flow(name, async (f) => f.done('declined'))
+ .on(github.pull_request('labeled'), async (f) => f.done('success')));
+ await expect(executeAuthoredFlow(
+ flow('software-factory', async (f) => f.done('success')),
+ new JournalClient('/unused'),
+ undefined,
+ {
+ extensions: [extension('one'), extension('two')],
+ extensionDispatch: integrationDispatch('pull_request.labeled'),
+ },
+ )).rejects.toMatchObject({ code: 'plugin_event_ambiguous' });
+ });
+
+ it('fails closed when a generic subscription overlaps an action subscription', async () => {
+ const generic = loadedExtension('generic', flow('generic', async (f) => f.done('declined'))
+ .on(github.pull_request(), async (f) => f.done('success')));
+ const action = loadedExtension('action', flow('action', async (f) => f.done('declined'))
+ .on(github.pull_request('labeled'), async (f) => f.done('success')));
+
+ await expect(executeAuthoredFlow(
+ flow('software-factory', async (f) => f.done('success')),
+ new JournalClient('/unused'),
+ undefined,
+ {
+ extensions: [generic, action],
+ extensionDispatch: integrationDispatch('pull_request.labeled'),
+ },
+ )).rejects.toMatchObject({ code: 'plugin_event_ambiguous' });
+ });
+
+ it.each([
+ ['an extra event segment', { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled.extra', deliveryId: 'delivery-1' }],
+ ['an invalid provider', { provenance: 'integration-watch', provider: 'GitHub', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' }],
+ ['a missing delivery id', { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled' }],
+ ['a caller-asserted provenance kind', { provenance: 'direct-run', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' }],
+ ])('refuses malformed hosted dispatch authority: %s', async (_case, extensionDispatch) => {
+ await expect(executeAuthoredFlow(
+ flow('software-factory', async (f) => f.done('success')),
+ new JournalClient('/unused'),
+ undefined,
+ { extensionDispatch: extensionDispatch as never },
+ )).rejects.toMatchObject({ code: 'plugin_event_unroutable' });
+ });
+
+ it('refuses a serialized copy of verified dispatch metadata', async () => {
+ const serialized = JSON.parse(JSON.stringify(integrationDispatch('pull_request.labeled')));
+ await expect(executeAuthoredFlow(
+ flow('software-factory', async (f) => f.done('success')),
+ new JournalClient('/unused'),
+ undefined,
+ { extensionDispatch: serialized as never },
+ )).rejects.toMatchObject({ code: 'plugin_event_unroutable' });
+ });
+
async function connectedClient(name: string): Promise {
const client = new JournalClient(path, { requestTimeoutMs: 2000 });
await client.connect();
@@ -577,6 +688,43 @@ describe('authored flow journal executor', () => {
}
});
+function integrationDispatch(eventType: string) {
+ return hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType, deliveryId: 'delivery-1' });
+}
+
+function loadedExtension(name: string, handle: ReturnType): LoadedFlowExtension {
+ const manifest: FlowExtensionManifest = {
+ schema: 2,
+ kind: 'flow-extension',
+ name,
+ version: '1.0.0',
+ compat: { surface: '*', sdk: '*', base: [{ name: 'software-factory', version: '*' }] },
+ entry: `${name}.flow.ts`,
+ extends: { handlers: true, hooks: [] },
+ triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }],
+ permissions: {
+ integrations: ['github'],
+ harnesses: [],
+ mcp: [],
+ writes: ['github:pull_request'],
+ },
+ preflight: { credentials: [], servers: [] },
+ };
+ return {
+ name,
+ version: '1.0.0',
+ ref: `github:AgentWorkforce/flows@${'a'.repeat(40)}#${name}`,
+ digest: 'b'.repeat(64),
+ directory: `/extensions/${name}`,
+ entryPath: `/extensions/${name}/${name}.flow.ts`,
+ manifest,
+ handle,
+ getDefinition: getFlowDefinition,
+ handlers: getFlowDefinition(handle).handlers,
+ hooks: Object.freeze({}),
+ };
+}
+
function outputFor(command: string): string {
if (command.startsWith('emit:')) return command.slice('emit:'.length);
return command === 'printf authored-journal-ok' ? 'authored-journal-ok' : '';
diff --git a/packages/sdk/tests/preflight.test.ts b/packages/sdk/tests/preflight.test.ts
index d7726699e..4dc843515 100644
--- a/packages/sdk/tests/preflight.test.ts
+++ b/packages/sdk/tests/preflight.test.ts
@@ -3,8 +3,13 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { addPlugin } from '../src/cli/add.js';
import { runPluginCommand } from '../src/cli/plugin.js';
+import {
+ extensionHandlerForHostedDispatch,
+ hostedExtensionDispatchFromVerifiedDelivery,
+} from '../src/flow-extension-loader.js';
import { SHA_A, fakeGithub, type FakeEntry } from './fake-github.js';
import type { PreflightFailureKind } from '../src/failure-kinds.js';
+import { PluginError } from '../src/plugin-manifest.js';
import { preflightHelpers } from '../src/preflight.js';
import { describe, expect, it } from 'vitest';
import {
@@ -693,6 +698,26 @@ describe('preflight: CLI resolution and refusal predicates', () => {
expect(await addPlugin(ref, io, { cwd: root, extension: { fetch, versions: { sdk: '2.0.22', surface: '2.0.22' } } })).toBe(2);
} finally { rmSync(root, { recursive: true, force: true }); }
}
+ // `plugin_event_ambiguous`: two installed extensions claim the same
+ // normalized hosted event. The dispatcher must refuse instead of
+ // silently selecting lock order and suppressing one handler.
+ const handler = {
+ trigger: {
+ kind: 'webhook', name: 'github',
+ filter: { provider: 'github', type: 'pull_request', payload: { action: 'labeled' } },
+ },
+ body: async () => {},
+ };
+ try {
+ extensionHandlerForHostedDispatch(
+ hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' }),
+ [{ name: 'one', handlers: [handler] }, { name: 'two', handlers: [handler] }] as never,
+ );
+ throw new Error('expected ambiguous extension dispatch to refuse');
+ } catch (error) {
+ expect(error).toBeInstanceOf(PluginError);
+ refusalKinds.push((error as PluginError).code);
+ }
// `plugin_lock_invalid`: a declaration with no lockfile entry behind it.
const root = mkdtempSync(join(tmpdir(), 'plugin-lock-taxonomy-'));
try {