@lumenflow/surfaces 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +40 -0
  3. package/cli/__tests__/gates.test.ts +97 -0
  4. package/cli/__tests__/inspect.test.ts +184 -0
  5. package/cli/__tests__/task-lifecycle.test.ts +203 -0
  6. package/cli/gates.ts +46 -0
  7. package/cli/index.ts +6 -0
  8. package/cli/inspect.ts +138 -0
  9. package/cli/task-lifecycle.ts +46 -0
  10. package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
  11. package/http/__tests__/auth-boundary.test.ts +57 -0
  12. package/http/__tests__/channel-send-governance.test.ts +158 -0
  13. package/http/__tests__/event-stream.test.ts +340 -0
  14. package/http/__tests__/phone-device-tool-api.test.ts +177 -0
  15. package/http/__tests__/remote-exposure.test.ts +212 -0
  16. package/http/__tests__/run-agent.test.ts +447 -0
  17. package/http/__tests__/scope-enforcement.test.ts +349 -0
  18. package/http/__tests__/sidecar-entry.test.ts +158 -0
  19. package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
  20. package/http/__tests__/tool-api.test.ts +491 -0
  21. package/http/__tests__/tool-discovery.test.ts +384 -0
  22. package/http/ag-ui-adapter.ts +352 -0
  23. package/http/auth.ts +294 -0
  24. package/http/control-plane-event-subscriber.ts +233 -0
  25. package/http/event-stream.ts +216 -0
  26. package/http/index.ts +10 -0
  27. package/http/run-agent.ts +416 -0
  28. package/http/server.ts +329 -0
  29. package/http/sidecar-entry.ts +218 -0
  30. package/http/task-api.ts +307 -0
  31. package/http/tool-api.ts +373 -0
  32. package/http/tool-discovery.ts +159 -0
  33. package/mcp/__tests__/server.test.ts +554 -0
  34. package/mcp/index.ts +4 -0
  35. package/mcp/server.ts +250 -0
  36. package/package.json +51 -0
@@ -0,0 +1,307 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import {
6
+ TaskSpecSchema,
7
+ type ClaimTaskInput,
8
+ type CompleteTaskInput,
9
+ type KernelRuntime,
10
+ } from '@lumenflow/kernel';
11
+
12
+ const HTTP_METHOD = {
13
+ GET: 'GET',
14
+ POST: 'POST',
15
+ } as const;
16
+
17
+ const HTTP_STATUS = {
18
+ OK: 200,
19
+ BAD_REQUEST: 400,
20
+ METHOD_NOT_ALLOWED: 405,
21
+ NOT_FOUND: 404,
22
+ INTERNAL_SERVER_ERROR: 500,
23
+ } as const;
24
+
25
+ const HEADER = {
26
+ CONTENT_TYPE: 'content-type',
27
+ } as const;
28
+
29
+ const CONTENT_TYPE = {
30
+ JSON: 'application/json; charset=utf-8',
31
+ } as const;
32
+
33
+ const ROUTE_ACTION = {
34
+ CLAIM: 'claim',
35
+ COMPLETE: 'complete',
36
+ } as const;
37
+
38
+ const JSON_BODY_EMPTY = '';
39
+ const JSON_RESPONSE_KEY_ERROR = 'error';
40
+ const JSON_RESPONSE_KEY_MESSAGE = 'message';
41
+ const UTF8_ENCODING = 'utf8';
42
+
43
+ class HttpSurfaceRequestError extends Error {
44
+ readonly statusCode: number;
45
+
46
+ constructor(message: string, statusCode: number = HTTP_STATUS.BAD_REQUEST) {
47
+ super(message);
48
+ this.statusCode = statusCode;
49
+ }
50
+ }
51
+
52
+ export interface TaskApiRouter {
53
+ handleRequest(
54
+ request: IncomingMessage,
55
+ response: ServerResponse<IncomingMessage>,
56
+ routeSegments: string[],
57
+ ): Promise<boolean>;
58
+ }
59
+
60
+ type JsonRecord = Record<string, unknown>;
61
+
62
+ function isJsonRecord(value: unknown): value is JsonRecord {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function readRequiredString(payload: JsonRecord, key: string, validationMessage: string): string {
67
+ const value = payload[key];
68
+ if (typeof value !== 'string' || value.trim().length === 0) {
69
+ throw new HttpSurfaceRequestError(validationMessage);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function readOptionalString(
75
+ payload: JsonRecord,
76
+ key: string,
77
+ validationMessage: string,
78
+ ): string | undefined {
79
+ const value = payload[key];
80
+ if (value === undefined) {
81
+ return undefined;
82
+ }
83
+ if (typeof value !== 'string' || value.trim().length === 0) {
84
+ throw new HttpSurfaceRequestError(validationMessage);
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function readOptionalObject(
90
+ payload: JsonRecord,
91
+ key: string,
92
+ validationMessage: string,
93
+ ): JsonRecord | undefined {
94
+ const value = payload[key];
95
+ if (value === undefined) {
96
+ return undefined;
97
+ }
98
+ if (!isJsonRecord(value)) {
99
+ throw new HttpSurfaceRequestError(validationMessage);
100
+ }
101
+ return value;
102
+ }
103
+
104
+ function readOptionalStringArray(
105
+ payload: JsonRecord,
106
+ key: string,
107
+ validationMessage: string,
108
+ ): string[] | undefined {
109
+ const value = payload[key];
110
+ if (value === undefined) {
111
+ return undefined;
112
+ }
113
+ if (!Array.isArray(value)) {
114
+ throw new HttpSurfaceRequestError(validationMessage);
115
+ }
116
+ if (value.some((item) => typeof item !== 'string' || item.length === 0)) {
117
+ throw new HttpSurfaceRequestError(validationMessage);
118
+ }
119
+ return value;
120
+ }
121
+
122
+ function writeJson(
123
+ response: ServerResponse<IncomingMessage>,
124
+ statusCode: number,
125
+ payload: unknown,
126
+ ): void {
127
+ response.statusCode = statusCode;
128
+ response.setHeader(HEADER.CONTENT_TYPE, CONTENT_TYPE.JSON);
129
+ response.end(JSON.stringify(payload));
130
+ }
131
+
132
+ async function readRequestBody(request: IncomingMessage): Promise<string> {
133
+ let body = JSON_BODY_EMPTY;
134
+ for await (const chunk of request) {
135
+ body += Buffer.isBuffer(chunk) ? chunk.toString(UTF8_ENCODING) : String(chunk);
136
+ }
137
+ return body;
138
+ }
139
+
140
+ async function readJsonRequestBody(request: IncomingMessage): Promise<unknown> {
141
+ const rawBody = await readRequestBody(request);
142
+ if (rawBody.trim().length === 0) {
143
+ return {};
144
+ }
145
+
146
+ try {
147
+ return JSON.parse(rawBody);
148
+ } catch {
149
+ throw new HttpSurfaceRequestError('Request body must be valid JSON.');
150
+ }
151
+ }
152
+
153
+ function assertJsonRecord(payload: unknown, message: string): JsonRecord {
154
+ if (!isJsonRecord(payload)) {
155
+ throw new HttpSurfaceRequestError(message);
156
+ }
157
+ return payload;
158
+ }
159
+
160
+ function toClaimTaskInput(taskId: string, payload: JsonRecord): ClaimTaskInput {
161
+ return {
162
+ task_id: taskId,
163
+ by: readRequiredString(payload, 'by', 'claim requires by.'),
164
+ session_id: readRequiredString(payload, 'session_id', 'claim requires session_id.'),
165
+ timestamp: readOptionalString(
166
+ payload,
167
+ 'timestamp',
168
+ 'claim timestamp must be a non-empty string.',
169
+ ),
170
+ domain_data: readOptionalObject(
171
+ payload,
172
+ 'domain_data',
173
+ 'claim domain_data must be a JSON object when provided.',
174
+ ),
175
+ };
176
+ }
177
+
178
+ function toCompleteTaskInput(taskId: string, payload: JsonRecord): CompleteTaskInput {
179
+ return {
180
+ task_id: taskId,
181
+ run_id: readOptionalString(payload, 'run_id', 'complete run_id must be a non-empty string.'),
182
+ timestamp: readOptionalString(
183
+ payload,
184
+ 'timestamp',
185
+ 'complete timestamp must be a non-empty string.',
186
+ ),
187
+ evidence_refs: readOptionalStringArray(
188
+ payload,
189
+ 'evidence_refs',
190
+ 'complete evidence_refs must be an array of non-empty strings.',
191
+ ),
192
+ };
193
+ }
194
+
195
+ function matchesCollectionRoute(routeSegments: string[]): boolean {
196
+ return routeSegments.length === 0;
197
+ }
198
+
199
+ function matchesTaskDetailRoute(routeSegments: string[]): boolean {
200
+ return routeSegments.length === 1;
201
+ }
202
+
203
+ function matchesTaskActionRoute(routeSegments: string[]): boolean {
204
+ return routeSegments.length === 2;
205
+ }
206
+
207
+ function writeUnknownRoute(response: ServerResponse<IncomingMessage>): void {
208
+ writeJson(response, HTTP_STATUS.NOT_FOUND, {
209
+ [JSON_RESPONSE_KEY_ERROR]: {
210
+ [JSON_RESPONSE_KEY_MESSAGE]: 'Route not found.',
211
+ },
212
+ });
213
+ }
214
+
215
+ function writeMethodNotAllowed(response: ServerResponse<IncomingMessage>, method: string): void {
216
+ writeJson(response, HTTP_STATUS.METHOD_NOT_ALLOWED, {
217
+ [JSON_RESPONSE_KEY_ERROR]: {
218
+ [JSON_RESPONSE_KEY_MESSAGE]: `Unsupported method: ${method}`,
219
+ },
220
+ });
221
+ }
222
+
223
+ function writeError(response: ServerResponse<IncomingMessage>, error: unknown): void {
224
+ if (error instanceof HttpSurfaceRequestError) {
225
+ writeJson(response, error.statusCode, {
226
+ [JSON_RESPONSE_KEY_ERROR]: {
227
+ [JSON_RESPONSE_KEY_MESSAGE]: error.message,
228
+ },
229
+ });
230
+ return;
231
+ }
232
+
233
+ writeJson(response, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
234
+ [JSON_RESPONSE_KEY_ERROR]: {
235
+ [JSON_RESPONSE_KEY_MESSAGE]: 'Internal server error.',
236
+ },
237
+ });
238
+ }
239
+
240
+ export function createTaskApiRouter(runtime: KernelRuntime): TaskApiRouter {
241
+ return {
242
+ async handleRequest(
243
+ request: IncomingMessage,
244
+ response: ServerResponse<IncomingMessage>,
245
+ routeSegments: string[],
246
+ ): Promise<boolean> {
247
+ const method = request.method ?? '';
248
+
249
+ try {
250
+ if (matchesCollectionRoute(routeSegments)) {
251
+ if (method !== HTTP_METHOD.POST) {
252
+ writeMethodNotAllowed(response, method);
253
+ return true;
254
+ }
255
+ const payload = await readJsonRequestBody(request);
256
+ const taskSpec = TaskSpecSchema.parse(payload);
257
+ const result = await runtime.createTask(taskSpec);
258
+ writeJson(response, HTTP_STATUS.OK, result);
259
+ return true;
260
+ }
261
+
262
+ if (matchesTaskDetailRoute(routeSegments)) {
263
+ if (method !== HTTP_METHOD.GET) {
264
+ writeMethodNotAllowed(response, method);
265
+ return true;
266
+ }
267
+ const taskId = routeSegments[0] ?? '';
268
+ const result = await runtime.inspectTask(taskId);
269
+ writeJson(response, HTTP_STATUS.OK, result);
270
+ return true;
271
+ }
272
+
273
+ if (matchesTaskActionRoute(routeSegments)) {
274
+ if (method !== HTTP_METHOD.POST) {
275
+ writeMethodNotAllowed(response, method);
276
+ return true;
277
+ }
278
+
279
+ const taskId = routeSegments[0] ?? '';
280
+ const action = routeSegments[1] ?? '';
281
+ const payload = assertJsonRecord(
282
+ await readJsonRequestBody(request),
283
+ 'Request body must be a JSON object.',
284
+ );
285
+
286
+ if (action === ROUTE_ACTION.CLAIM) {
287
+ const result = await runtime.claimTask(toClaimTaskInput(taskId, payload));
288
+ writeJson(response, HTTP_STATUS.OK, result);
289
+ return true;
290
+ }
291
+
292
+ if (action === ROUTE_ACTION.COMPLETE) {
293
+ const result = await runtime.completeTask(toCompleteTaskInput(taskId, payload));
294
+ writeJson(response, HTTP_STATUS.OK, result);
295
+ return true;
296
+ }
297
+ }
298
+
299
+ writeUnknownRoute(response);
300
+ return true;
301
+ } catch (error) {
302
+ writeError(response, error);
303
+ return true;
304
+ }
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,373 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import {
6
+ ExecutionContextSchema,
7
+ TOOL_ERROR_CODES,
8
+ type ExecutionContext,
9
+ type KernelRuntime,
10
+ type ToolOutput,
11
+ } from '@lumenflow/kernel';
12
+ import {
13
+ authorizeToolRequest,
14
+ createMissingScopesPayload,
15
+ resolveAuthoritativeFrom,
16
+ } from './auth.js';
17
+
18
+ const HTTP_METHOD = {
19
+ POST: 'POST',
20
+ } as const;
21
+
22
+ const HTTP_STATUS = {
23
+ OK: 200,
24
+ BAD_REQUEST: 400,
25
+ UNAUTHORIZED: 401,
26
+ FORBIDDEN: 403,
27
+ NOT_FOUND: 404,
28
+ METHOD_NOT_ALLOWED: 405,
29
+ INTERNAL_SERVER_ERROR: 500,
30
+ } as const;
31
+
32
+ const HEADER = {
33
+ CONTENT_TYPE: 'content-type',
34
+ } as const;
35
+
36
+ const CONTENT_TYPE = {
37
+ JSON: 'application/json; charset=utf-8',
38
+ } as const;
39
+
40
+ const RESPONSE_KEYS = {
41
+ ERROR: 'error',
42
+ MESSAGE: 'message',
43
+ } as const;
44
+
45
+ /**
46
+ * WU-2731 (ADR-013 §5): Metadata key under which the authoritative `from`
47
+ * claim is written onto the execution context. Tools (and audit sinks) read
48
+ * this for per-device attribution. The body-supplied `from` field (if any)
49
+ * is stripped before reaching tool execution.
50
+ */
51
+ const METADATA_KEY_FROM = 'from' as const;
52
+ const METADATA_KEY_FROM_SOURCE = 'from_source' as const;
53
+ const FROM_SOURCE_TOKEN = 'token' as const;
54
+ const BODY_FIELD_FROM = 'from' as const;
55
+
56
+ const UTF8_ENCODING = 'utf8';
57
+
58
+ /**
59
+ * WU-2780 (ADR-013 §6): tool names the HTTP tool-api surface MUST NOT expose
60
+ * as top-level remote-callable targets. These are runtime-only tools: the
61
+ * kernel still dispatches them via `agent:execute-turn` through the governed
62
+ * manifest path, but POST /tools/:name is never a legitimate entry point.
63
+ *
64
+ * ADR-013 §6 (`channel.send` governance) states: "The sidekick pack does NOT
65
+ * expose `channel.send` as a top-level surface the agent can call outside a
66
+ * turn. It is registered only as a runtime-callable tool." Every
67
+ * phone-directed message MUST become an `agent-runtime:tool_called` event
68
+ * inside a turn so the audit trail stays complete.
69
+ *
70
+ * The enforcement is fail-closed: `createToolApiRouter` throws synchronously
71
+ * at construction if any forbidden tool name appears in `allowlistedTools`.
72
+ * No log-only warning. This closes the historical leak where sidekick's
73
+ * `channel:send` was declared as a normal pack tool and became directly
74
+ * callable via POST /tools/channel:send.
75
+ */
76
+ export const ADR_013_SECTION_6_FORBIDDEN_REMOTE_TOOLS: readonly string[] = [
77
+ 'channel:send',
78
+ ] as const;
79
+
80
+ const GOVERNANCE_ERROR_PREFIX =
81
+ 'ADR-013 §6 governance violation: the following tool(s) must not be exposed on the HTTP tool-api (POST /tools/:name) because they are routed only through agent:execute-turn:';
82
+
83
+ interface ToolApiRequestBody {
84
+ input?: unknown;
85
+ context: ExecutionContext;
86
+ }
87
+
88
+ interface JsonRecord {
89
+ [key: string]: unknown;
90
+ }
91
+
92
+ class ToolApiRequestError extends Error {
93
+ readonly statusCode: number;
94
+
95
+ constructor(message: string, statusCode: number = HTTP_STATUS.BAD_REQUEST) {
96
+ super(message);
97
+ this.statusCode = statusCode;
98
+ }
99
+ }
100
+
101
+ export interface ToolApiRouterOptions {
102
+ allowlistedTools: readonly string[];
103
+ }
104
+
105
+ export interface ToolApiRouter {
106
+ handleRequest(
107
+ request: IncomingMessage,
108
+ response: ServerResponse<IncomingMessage>,
109
+ routeSegments: string[],
110
+ ): Promise<boolean>;
111
+ }
112
+
113
+ function isJsonRecord(value: unknown): value is JsonRecord {
114
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
115
+ }
116
+
117
+ function writeJson(
118
+ response: ServerResponse<IncomingMessage>,
119
+ statusCode: number,
120
+ payload: unknown,
121
+ ): void {
122
+ response.statusCode = statusCode;
123
+ response.setHeader(HEADER.CONTENT_TYPE, CONTENT_TYPE.JSON);
124
+ response.end(JSON.stringify(payload));
125
+ }
126
+
127
+ function writeMethodNotAllowed(response: ServerResponse<IncomingMessage>, method: string): void {
128
+ writeJson(response, HTTP_STATUS.METHOD_NOT_ALLOWED, {
129
+ [RESPONSE_KEYS.ERROR]: {
130
+ [RESPONSE_KEYS.MESSAGE]: `Unsupported method: ${method}`,
131
+ },
132
+ });
133
+ }
134
+
135
+ function writeUnknownRoute(response: ServerResponse<IncomingMessage>): void {
136
+ writeJson(response, HTTP_STATUS.NOT_FOUND, {
137
+ [RESPONSE_KEYS.ERROR]: {
138
+ [RESPONSE_KEYS.MESSAGE]: 'Route not found.',
139
+ },
140
+ });
141
+ }
142
+
143
+ function writeError(response: ServerResponse<IncomingMessage>, error: unknown): void {
144
+ if (error instanceof ToolApiRequestError) {
145
+ writeJson(response, error.statusCode, {
146
+ [RESPONSE_KEYS.ERROR]: {
147
+ [RESPONSE_KEYS.MESSAGE]: error.message,
148
+ },
149
+ });
150
+ return;
151
+ }
152
+
153
+ writeJson(response, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
154
+ [RESPONSE_KEYS.ERROR]: {
155
+ [RESPONSE_KEYS.MESSAGE]: 'Internal server error.',
156
+ },
157
+ });
158
+ }
159
+
160
+ async function readRequestBody(request: IncomingMessage): Promise<string> {
161
+ let body = '';
162
+ for await (const chunk of request) {
163
+ body += Buffer.isBuffer(chunk) ? chunk.toString(UTF8_ENCODING) : String(chunk);
164
+ }
165
+ return body;
166
+ }
167
+
168
+ async function readJsonRequestBody(request: IncomingMessage): Promise<unknown> {
169
+ const rawBody = await readRequestBody(request);
170
+ if (rawBody.trim().length === 0) {
171
+ return {};
172
+ }
173
+
174
+ try {
175
+ return JSON.parse(rawBody);
176
+ } catch {
177
+ throw new ToolApiRequestError('Request body must be valid JSON.');
178
+ }
179
+ }
180
+
181
+ function toToolApiBody(payload: unknown): ToolApiRequestBody {
182
+ if (!isJsonRecord(payload)) {
183
+ throw new ToolApiRequestError('Request body must be a JSON object.');
184
+ }
185
+
186
+ if (!('context' in payload)) {
187
+ throw new ToolApiRequestError('context is required.');
188
+ }
189
+
190
+ let context: ExecutionContext;
191
+ try {
192
+ context = ExecutionContextSchema.parse(payload.context);
193
+ } catch (error) {
194
+ throw new ToolApiRequestError((error as Error).message);
195
+ }
196
+
197
+ return {
198
+ input: payload.input,
199
+ context,
200
+ };
201
+ }
202
+
203
+ function mapToolOutputStatus(output: ToolOutput): number {
204
+ if (output.success) {
205
+ return HTTP_STATUS.OK;
206
+ }
207
+
208
+ const code = output.error?.code;
209
+ if (code === TOOL_ERROR_CODES.TOOL_NOT_FOUND) {
210
+ return HTTP_STATUS.NOT_FOUND;
211
+ }
212
+ if (
213
+ code === TOOL_ERROR_CODES.POLICY_DENIED ||
214
+ code === TOOL_ERROR_CODES.SCOPE_DENIED ||
215
+ code === TOOL_ERROR_CODES.APPROVAL_REQUIRED
216
+ ) {
217
+ return HTTP_STATUS.FORBIDDEN;
218
+ }
219
+ if (code === TOOL_ERROR_CODES.INVALID_INPUT) {
220
+ return HTTP_STATUS.BAD_REQUEST;
221
+ }
222
+
223
+ return HTTP_STATUS.OK;
224
+ }
225
+
226
+ /**
227
+ * WU-2731 (ADR-013 §5): Remove any `from` field from the request-body's
228
+ * `context.metadata`. Cloud MUST NOT trust the body-supplied `from` — the
229
+ * authoritative value comes from the authenticated token subject. We also
230
+ * drop top-level `context.from` if a caller tried to smuggle identity there.
231
+ */
232
+ function stripBodyFromField(payload: unknown): unknown {
233
+ if (!isJsonRecord(payload)) {
234
+ return payload;
235
+ }
236
+
237
+ const cloned: JsonRecord = { ...payload };
238
+ const context = cloned.context;
239
+ if (!isJsonRecord(context)) {
240
+ return cloned;
241
+ }
242
+
243
+ const sanitizedContext = stripKeyFromRecord(context, BODY_FIELD_FROM);
244
+ const metadata = sanitizedContext.metadata;
245
+ if (isJsonRecord(metadata) && BODY_FIELD_FROM in metadata) {
246
+ sanitizedContext.metadata = stripKeyFromRecord(metadata, BODY_FIELD_FROM);
247
+ }
248
+ cloned.context = sanitizedContext;
249
+ return cloned;
250
+ }
251
+
252
+ function stripKeyFromRecord(record: JsonRecord, key: string): JsonRecord {
253
+ const result: JsonRecord = {};
254
+ for (const [entryKey, entryValue] of Object.entries(record)) {
255
+ if (entryKey !== key) {
256
+ result[entryKey] = entryValue;
257
+ }
258
+ }
259
+ return result;
260
+ }
261
+
262
+ function injectAuthoritativeFrom(context: ExecutionContext, from: string): ExecutionContext {
263
+ const baseMetadata =
264
+ context.metadata && typeof context.metadata === 'object' ? context.metadata : {};
265
+ return {
266
+ ...context,
267
+ metadata: {
268
+ ...baseMetadata,
269
+ [METADATA_KEY_FROM]: from,
270
+ [METADATA_KEY_FROM_SOURCE]: FROM_SOURCE_TOKEN,
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * WU-2780 (ADR-013 §6): fail-closed construction guard. Runs before the
277
+ * router is built so violations are caught at process startup, never at
278
+ * request time. Throws a contextual error naming every offending tool and
279
+ * citing ADR-013 §6 so the operator knows exactly what to remove.
280
+ */
281
+ function enforceAdr013Section6RemoteAllowlist(allowlistedTools: readonly string[]): void {
282
+ const forbidden = new Set(ADR_013_SECTION_6_FORBIDDEN_REMOTE_TOOLS);
283
+ const violators = allowlistedTools.filter((name) => forbidden.has(name));
284
+ if (violators.length === 0) {
285
+ return;
286
+ }
287
+ throw new Error(
288
+ `${GOVERNANCE_ERROR_PREFIX} ${violators.join(', ')}. ` +
289
+ `These tools stay registered as runtime-callable so agent:execute-turn ` +
290
+ `can dispatch them, but they MUST NOT appear in the HTTP surface ` +
291
+ `allowlist. Remove them from allowlistedTools or route callers through ` +
292
+ `agent:execute-turn instead. See ADR-013 §6.`,
293
+ );
294
+ }
295
+
296
+ export function createToolApiRouter(
297
+ runtime: KernelRuntime,
298
+ options: ToolApiRouterOptions,
299
+ ): ToolApiRouter {
300
+ enforceAdr013Section6RemoteAllowlist(options.allowlistedTools);
301
+ const allowlistedTools = new Set(options.allowlistedTools);
302
+
303
+ return {
304
+ async handleRequest(
305
+ request: IncomingMessage,
306
+ response: ServerResponse<IncomingMessage>,
307
+ routeSegments: string[],
308
+ ): Promise<boolean> {
309
+ const method = request.method ?? '';
310
+
311
+ try {
312
+ if (routeSegments.length !== 1) {
313
+ writeUnknownRoute(response);
314
+ return true;
315
+ }
316
+
317
+ if (method !== HTTP_METHOD.POST) {
318
+ writeMethodNotAllowed(response, method);
319
+ return true;
320
+ }
321
+
322
+ const toolName = routeSegments[0] ?? '';
323
+ if (!allowlistedTools.has(toolName)) {
324
+ writeJson(response, HTTP_STATUS.FORBIDDEN, {
325
+ success: false,
326
+ error: {
327
+ code: 'TOOL_NOT_ALLOWLISTED',
328
+ message: `Tool "${toolName}" is not allowlisted for HTTP dispatch.`,
329
+ },
330
+ });
331
+ return true;
332
+ }
333
+
334
+ const authorization = authorizeToolRequest(request.headers, toolName);
335
+ if (!authorization.allowed) {
336
+ // WU-2779: distinguish missing credentials (401) from scope denial
337
+ // (403). The tool is NOT dispatched in either case.
338
+ if (authorization.reason === 'missing_authorization') {
339
+ writeJson(response, HTTP_STATUS.UNAUTHORIZED, {
340
+ error: 'missing_authorization',
341
+ message: 'Authorization header is required.',
342
+ });
343
+ return true;
344
+ }
345
+ writeJson(
346
+ response,
347
+ HTTP_STATUS.FORBIDDEN,
348
+ createMissingScopesPayload(authorization, toolName),
349
+ );
350
+ return true;
351
+ }
352
+
353
+ const rawBody = await readJsonRequestBody(request);
354
+ // WU-2731 (ADR-013 §5): strip any body-supplied `from` field before
355
+ // parsing. Cloud audit attribution comes from the token subject, not
356
+ // the payload; keeping the body field would let token holders spoof
357
+ // per-device identity.
358
+ const sanitizedBody = stripBodyFromField(rawBody);
359
+ const body = toToolApiBody(sanitizedBody);
360
+ const authoritativeFrom = resolveAuthoritativeFrom(request.headers);
361
+ const executionContext = authoritativeFrom
362
+ ? injectAuthoritativeFrom(body.context, authoritativeFrom.from)
363
+ : body.context;
364
+ const output = await runtime.executeTool(toolName, body.input ?? {}, executionContext);
365
+ writeJson(response, mapToolOutputStatus(output), output);
366
+ return true;
367
+ } catch (error) {
368
+ writeError(response, error);
369
+ return true;
370
+ }
371
+ },
372
+ };
373
+ }