From d20e78a5dfb2125143f12499e9864837cfbd2ba6 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 3 Jul 2026 02:47:08 -0400 Subject: [PATCH] SEP Phase 2 (SDK + spec): hook handlers + observe event bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extension-sdk: smooth.on(name, handler) now covers intercept hooks — return {block,reason} to veto or {patch} to rewrite input; the extension folds its own handlers (first block wins, patches shallow-merge and thread) and the host chains across extensions. Hook names stay out of event subscriptions. createTestHost gains callHook(). New permission-gate demo blocks dangerous bash via a fail-closed tool_call hook. spec/extension: event schema gains optional seq (absent on the events_lost marker) + model_select->ModelResolved parity note; fixtures add a seq'd event, the events_lost marker, tool_execution_start, and the tool_result hook pair. Rust + TS conformance green. Co-Authored-By: Claude Fable 5 --- .changeset/sep-phase2-sdk.md | 23 ++++++++ spec/extension/conformance/fixtures.json | 42 ++++++++++++++- spec/extension/methods/event.schema.json | 9 +++- .../extension-sdk/examples/permission-gate.ts | 37 +++++++++++++ typescript/extension-sdk/src/extension.ts | 39 ++++++++++++-- typescript/extension-sdk/src/index.ts | 4 +- typescript/extension-sdk/src/protocol.ts | 15 ++++++ typescript/extension-sdk/src/test-host.ts | 7 ++- typescript/extension-sdk/test/hook.test.ts | 54 +++++++++++++++++++ 9 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 .changeset/sep-phase2-sdk.md create mode 100644 typescript/extension-sdk/examples/permission-gate.ts create mode 100644 typescript/extension-sdk/test/hook.test.ts diff --git a/.changeset/sep-phase2-sdk.md b/.changeset/sep-phase2-sdk.md new file mode 100644 index 0000000..a829cdb --- /dev/null +++ b/.changeset/sep-phase2-sdk.md @@ -0,0 +1,23 @@ +--- +"@smooai/smooth-extension-sdk": minor +"@smooai/smooth-operator": patch +--- + +SEP Phase 2 (SDK + spec) — hooks + the observe event bus. + +`@smooai/smooth-extension-sdk` gains **hook handlers**: `smooth.on(name, handler)` +now covers both observe events (return ignored) and intercept hooks (return a +`HookResult` — `{ block, reason? }` to veto or `{ patch }` to rewrite the input). +The extension answers the `hook` request by folding its own handlers in +registration order (first `block` short-circuits; `patch`es shallow-merge and +thread to the next), and the host chains the outcome across extensions. Hook +names are kept out of the reported event `subscriptions`. `createTestHost` gains +`callHook(hook, input)`; new `permission-gate` demo extension blocks dangerous +`bash` commands via a fail-closed `tool_call` hook. + +`spec/extension`: the event schema gains an optional `seq` (per-connection +monotonic sequence; absent on the out-of-band `events_lost` marker) with a +`model_select → AgentEvent::ModelResolved` parity note, and fixtures add a +seq-numbered event, the `events_lost` marker (drop-N → count), a +`tool_execution_start` event, and the `tool_result` hook input + a result-shaped +`modify` outcome. Rust and TypeScript conformance replays stay green. diff --git a/spec/extension/conformance/fixtures.json b/spec/extension/conformance/fixtures.json index 85cd413..0206436 100644 --- a/spec/extension/conformance/fixtures.json +++ b/spec/extension/conformance/fixtures.json @@ -62,11 +62,33 @@ "event_params": { "$schema_ref": "methods/event.schema.json#/$defs/Params", - "description": "A turn_start lifecycle event delivered to a subscribed extension.", + "description": "A turn_start lifecycle event delivered to a subscribed extension, carrying its per-connection sequence number.", "instance": { "event": "turn_start", + "seq": 41, "context": { "token": "epoch-7", "tier": "event" }, - "payload": { "turn_id": "turn-42" } + "payload": { "agent_id": "agent-1" } + } + }, + + "event_tool_execution_start": { + "$schema_ref": "methods/event.schema.json#/$defs/Params", + "description": "A tool_execution_start observe event (pi-aligned name), seq-numbered.", + "instance": { + "event": "tool_execution_start", + "seq": 42, + "context": { "token": "epoch-7", "tier": "event" }, + "payload": { "iteration": 1, "tool": "bash" } + } + }, + + "event_events_lost": { + "$schema_ref": "methods/event.schema.json#/$defs/Params", + "description": "The out-of-band events_lost marker delivered after the bounded observe queue shed 12 oldest events. It carries the shed count and a context but NO seq — the gap in the surrounding seq run is itself the loss signal.", + "instance": { + "event": "events_lost", + "context": { "token": "epoch-7", "tier": "event" }, + "payload": { "lost": 12 } } }, @@ -98,6 +120,22 @@ "instance": { "action": "modify", "patch": { "tool": "bash", "arguments": { "command": "echo blocked" } } } }, + "hook_params_tool_result": { + "$schema_ref": "methods/hook.schema.json#/$defs/Params", + "description": "A tool_result intercept (fail-open): the completed result is offered to the extension before it is pushed to the conversation.", + "instance": { + "hook": "tool_result", + "context": { "token": "epoch-7", "tier": "command" }, + "input": { "tool": "bash", "arguments": { "command": "ls" }, "content": "total 0\n", "is_error": false } + } + }, + + "hook_outcome_modify_tool_result": { + "$schema_ref": "methods/hook.schema.json#/$defs/HookOutcome", + "description": "A tool_result modify that rewrites the content the agent sees (e.g. redaction).", + "instance": { "action": "modify", "patch": { "content": "[redacted]", "is_error": false } } + }, + "tool_execute_params": { "$schema_ref": "methods/tool-execute.schema.json#/$defs/Params", "description": "Dispatch of the echo extension's `say` tool.", diff --git a/spec/extension/methods/event.schema.json b/spec/extension/methods/event.schema.json index d2e24a4..d5453c2 100644 --- a/spec/extension/methods/event.schema.json +++ b/spec/extension/methods/event.schema.json @@ -13,7 +13,12 @@ "properties": { "event": { "type": "string", - "description": "Event name, e.g. `turn_start`, `turn_end`, `message_end`, `tool_call_update`, `session_start`, `events_lost`." + "description": "Event name. Names mirror pi's for near-mechanical porting: `agent_start`/`agent_end`, `turn_start`/`turn_end`, `message_start`/`message_update`/`message_end`, `tool_execution_start`/`tool_execution_update`/`tool_execution_end`, `model_select` (the engine emits this as `AgentEvent::ModelResolved`), `session_start`/`session_compact`/`session_shutdown`/`session_tree`. The out-of-band `events_lost` marker is delivered after shedding." + }, + "seq": { + "type": "integer", + "minimum": 0, + "description": "Per-connection monotonic sequence number. A gap in the run signals that the bounded observe queue shed events; the following `events_lost` marker carries the exact count. Absent on the `events_lost` marker itself (it is out-of-band)." }, "context": { "type": "object", @@ -24,7 +29,7 @@ "tier": { "type": "string", "enum": ["event", "command"] } } }, - "payload": { "type": "object", "description": "Event-specific data; extensions read what they understand." } + "payload": { "type": "object", "description": "Event-specific data; extensions read what they understand. For `events_lost`: `{ \"lost\": }`." } } } }, diff --git a/typescript/extension-sdk/examples/permission-gate.ts b/typescript/extension-sdk/examples/permission-gate.ts new file mode 100644 index 0000000..a449cea --- /dev/null +++ b/typescript/extension-sdk/examples/permission-gate.ts @@ -0,0 +1,37 @@ +/** + * `permission-gate` — the Phase 2 demo extension. It vetoes dangerous `bash` + * commands via the fail-closed `tool_call` hook before the tool ever runs. + * + * Run it as a real SEP subprocess: `tsx examples/permission-gate.ts` + * The host runs this hook before executing any tool; a `block` stops the call. + * Because `tool_call` is fail-closed, if this process hangs or crashes the host + * times out and blocks the tool anyway — safe by default. + */ +import { defineExtension } from '../src/index.js'; + +/** Commands that should never run unattended. Add patterns as needed. */ +const DANGEROUS: RegExp[] = [ + /\brm\s+-[a-z]*[rf]/, // rm -rf / rm -fr / rm -r ... + /\bmkfs\b/, // format a filesystem + /\bdd\s+.*\bof=\/dev\//, // dd onto a raw device + />\s*\/dev\/sd[a-z]/, // redirect onto a raw disk + /:\(\)\s*\{.*:\|:.*\}\s*;\s*:/, // classic fork bomb + /\bchmod\s+-R\s+0*777\s+\//, // chmod -R 777 / +]; + +export const permissionGate = defineExtension((smooth) => { + smooth.name = 'permission-gate'; + smooth.version = '0.1.0'; + + smooth.on('tool_call', (input) => { + if (input?.tool !== 'bash') return; + const command = String((input.arguments as Record | undefined)?.command ?? ''); + const hit = DANGEROUS.find((re) => re.test(command)); + if (hit) return { block: true, reason: `blocked dangerous command (matched ${hit})` }; + }); +}); + +// When run directly (not imported by a test), serve over stdio. +if (import.meta.url === `file://${process.argv[1]}`) { + permissionGate.serve(); +} diff --git a/typescript/extension-sdk/src/extension.ts b/typescript/extension-sdk/src/extension.ts index ed75053..d59ffec 100644 --- a/typescript/extension-sdk/src/extension.ts +++ b/typescript/extension-sdk/src/extension.ts @@ -12,10 +12,14 @@ */ import { Peer } from './jsonrpc.js'; import { PROTOCOL_VERSION, method } from './protocol.js'; -import type { Context, EventParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; +import type { Context, EventParams, HookOutcome, HookParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; import { toJsonSchema, type ParameterSchema } from './schema.js'; import { stdioTransport, type Transport } from './transport.js'; +/** The intercept hooks (awaited, host-orchestrated); every other `on(...)` name + * is a fire-and-forget observe event. Kept in sync with the engine's HookType. */ +const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']); + /** Progress + cancellation handed to a tool while it runs. */ export interface ToolContext { /** Correlates `onUpdate` calls with this execution. */ @@ -44,8 +48,14 @@ export function defineTool>(def: ToolDef) return def; } -/** Handler for an observe `event`. Fire-and-forget; return value ignored. */ -export type EventHandler = (payload: Record | undefined, ctx: Context) => void | Promise; +/** A hook handler's friendly return: veto the operation, or replace its input + * with a patch (shallow-merged onto the input). Returning nothing = continue. */ +export type HookResult = { block: true; reason?: string } | { patch: Record }; + +/** Handler for an `on(name, ...)` registration. For an observe event the return + * is ignored; for an intercept hook (see {@link HOOK_NAMES}) return a + * {@link HookResult} to veto or patch. Mirrors pi's single `on`. */ +export type EventHandler = (data: Record | undefined, ctx: Context) => void | HookResult | Promise; /** The builder passed to `defineExtension`'s setup. Mirrors pi's `ExtensionAPI`. */ export interface SmoothApi { @@ -114,6 +124,7 @@ export class Extension { return {}; }); peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal)); + peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams)); peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams)); transport.start((frame) => peer.receive(frame)); @@ -141,10 +152,13 @@ export class Extension { parameters: toJsonSchema(t.parameters), ...(t.deferred ? { deferred: true } : {}), })); + // Only observe events go in `subscriptions` — hook names are intercepts + // the host always calls, not events it filters by subscription. + const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name)); return { protocol_version: PROTOCOL_VERSION, extension: { name: this.name, version: this.version }, - registrations: { tools, subscriptions: [...this.events.keys()] }, + registrations: { tools, subscriptions }, }; } @@ -166,6 +180,23 @@ export class Extension { void handler(params.payload, params.context); } } + + /** Fold this extension's handlers for one `hook` into a single outcome: the + * first `block` short-circuits; `patch`es shallow-merge onto the input and + * thread to the next handler; no result = continue. The host chains the + * outcome across extensions in load order. */ + private async dispatchHook(params: HookParams): Promise { + let input = params.input; + let modified = false; + for (const handler of this.events.get(params.hook) ?? []) { + const result = await handler(input, params.context); + if (!result) continue; + if ('block' in result) return { action: 'block', ...(result.reason ? { reason: result.reason } : {}) }; + input = { ...input, ...result.patch }; + modified = true; + } + return modified ? { action: 'modify', patch: input } : { action: 'continue' }; + } } /** Define an extension. Set `smooth.name`/`smooth.version` and register tools. */ diff --git a/typescript/extension-sdk/src/index.ts b/typescript/extension-sdk/src/index.ts index f5c36cb..91fd20b 100644 --- a/typescript/extension-sdk/src/index.ts +++ b/typescript/extension-sdk/src/index.ts @@ -9,7 +9,7 @@ * `runConformance`. */ export { defineExtension, defineTool, Extension } from './extension.js'; -export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, ConnectHandle } from './extension.js'; +export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle } from './extension.js'; export { createTestHost } from './test-host.js'; export type { TestHost, CallToolOptions } from './test-host.js'; export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js'; @@ -31,4 +31,6 @@ export type { ToolExecuteResult, ToolUpdateParams, EventParams, + HookParams, + HookOutcome, } from './protocol.js'; diff --git a/typescript/extension-sdk/src/protocol.ts b/typescript/extension-sdk/src/protocol.ts index 741e357..241ae55 100644 --- a/typescript/extension-sdk/src/protocol.ts +++ b/typescript/extension-sdk/src/protocol.ts @@ -112,6 +112,21 @@ export interface ToolUpdateParams { export interface EventParams { event: string; + /** Per-connection monotonic sequence; absent on the `events_lost` marker. */ + seq?: number; context: Context; payload?: Record; } + +/** Host → ext `hook` request: an awaited intercept the extension can veto/patch. */ +export interface HookParams { + hook: string; + context: Context; + input: Record; +} + +/** The extension's reply to a `hook`, tagged by `action`. */ +export type HookOutcome = + | { action: 'continue' } + | { action: 'block'; reason?: string } + | { action: 'modify'; patch: Record }; diff --git a/typescript/extension-sdk/src/test-host.ts b/typescript/extension-sdk/src/test-host.ts index 7e95cd4..bdc9a59 100644 --- a/typescript/extension-sdk/src/test-host.ts +++ b/typescript/extension-sdk/src/test-host.ts @@ -7,7 +7,7 @@ */ import { Peer } from './jsonrpc.js'; import { PROTOCOL_VERSION, method } from './protocol.js'; -import type { Context, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; +import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; import type { Extension } from './extension.js'; import { linkedPair } from './transport.js'; @@ -25,6 +25,8 @@ export interface CallToolOptions { export interface TestHost { initialize(overrides?: Partial): Promise; callTool(tool: string, args: Record, opts?: CallToolOptions): Promise; + /** Drive a `hook` request and get back the extension's folded outcome. */ + callHook(hook: string, input: Record, context?: Context): Promise; ping(): Promise>; sendEvent(event: string, payload?: Record, context?: Context): void; shutdown(): Promise; @@ -74,6 +76,9 @@ export function createTestHost(extension: Extension): TestHost { updateSinks.delete(call_id); } }, + callHook(hook, input, context) { + return host.request(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT }); + }, ping() { return host.request>(method.PING, {}); }, diff --git a/typescript/extension-sdk/test/hook.test.ts b/typescript/extension-sdk/test/hook.test.ts new file mode 100644 index 0000000..c04879e --- /dev/null +++ b/typescript/extension-sdk/test/hook.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { createTestHost, defineExtension } from '../src/index.js'; +import { permissionGate } from '../examples/permission-gate.js'; + +describe('hook handlers', () => { + it('permission-gate blocks a dangerous bash command via tool_call', async () => { + const host = createTestHost(permissionGate); + await host.initialize(); + + const blocked = await host.callHook('tool_call', { tool: 'bash', arguments: { command: 'rm -rf /' } }); + expect(blocked.action).toBe('block'); + + const ok = await host.callHook('tool_call', { tool: 'bash', arguments: { command: 'ls -la' } }); + expect(ok.action).toBe('continue'); + host.close(); + }); + + it('a patch outcome shallow-merges onto the input', async () => { + const ext = defineExtension((smooth) => { + smooth.name = 'redactor'; + smooth.on('tool_result', () => ({ patch: { content: '[redacted]' } })); + }); + const host = createTestHost(ext); + await host.initialize(); + + const outcome = await host.callHook('tool_result', { tool: 'bash', content: 'secret', is_error: false }); + expect(outcome).toEqual({ action: 'modify', patch: { tool: 'bash', content: '[redacted]', is_error: false } }); + host.close(); + }); + + it('first block short-circuits later handlers', async () => { + const ext = defineExtension((smooth) => { + smooth.name = 'multi'; + smooth.on('tool_call', () => ({ block: true, reason: 'first' })); + smooth.on('tool_call', () => ({ patch: { should: 'not apply' } })); + }); + const host = createTestHost(ext); + await host.initialize(); + expect(await host.callHook('tool_call', { tool: 'bash' })).toEqual({ action: 'block', reason: 'first' }); + host.close(); + }); + + it('hook names are not reported as event subscriptions', async () => { + const ext = defineExtension((smooth) => { + smooth.name = 'mixed'; + smooth.on('tool_call', () => undefined); + smooth.on('turn_start', () => undefined); + }); + const host = createTestHost(ext); + const init = await host.initialize(); + expect(init.registrations?.subscriptions).toEqual(['turn_start']); + host.close(); + }); +});