@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.
- package/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WU-2731 (INIT-060 phase 3, ADR-013 §5): HTTP tool-api enforces the
|
|
6
|
+
* authoritative `from` rule for phone-scoped tokens.
|
|
7
|
+
*
|
|
8
|
+
* Acceptance criteria covered here:
|
|
9
|
+
* - Inbound POST /tools/:name populates the `from` field on the execution
|
|
10
|
+
* context from the authenticated token subject.
|
|
11
|
+
* - Request-body `from` is IGNORED (body-supplied attribution cannot
|
|
12
|
+
* override the authoritative value).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
16
|
+
import { EventEmitter } from 'node:events';
|
|
17
|
+
import { PassThrough } from 'node:stream';
|
|
18
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { issueControlPlaneIdentity } from '../../../control-plane-sdk/src/authenticate.js';
|
|
21
|
+
import { createToolApiRouter } from '../tool-api.js';
|
|
22
|
+
|
|
23
|
+
const HTTP_METHOD_POST = 'POST';
|
|
24
|
+
const HTTP_STATUS_OK = 200;
|
|
25
|
+
const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
|
|
26
|
+
const TOOL_NAME = 'orchestrate:initiative';
|
|
27
|
+
const WORKSPACE_ID = 'ws-wu-2731';
|
|
28
|
+
const DEVICE_ID = 'dev-wu-2731-iphone';
|
|
29
|
+
const PHONE_SUBJECT = `${WORKSPACE_ID}:phone:${DEVICE_ID}`;
|
|
30
|
+
const SPOOFED_SUBJECT = 'ws-other-tenant:phone:attacker';
|
|
31
|
+
const METADATA_KEY_FROM = 'from';
|
|
32
|
+
const METADATA_KEY_FROM_SOURCE = 'from_source';
|
|
33
|
+
|
|
34
|
+
class MockResponse extends EventEmitter {
|
|
35
|
+
statusCode = HTTP_STATUS_OK;
|
|
36
|
+
body = '';
|
|
37
|
+
private readonly headers = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
40
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
write(chunk: string | Buffer): boolean {
|
|
45
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
end(chunk?: string | Buffer): this {
|
|
50
|
+
if (chunk !== undefined) {
|
|
51
|
+
this.write(chunk);
|
|
52
|
+
}
|
|
53
|
+
this.emit('finish');
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createRequest(options: { body: unknown; authorization?: string }): IncomingMessage {
|
|
59
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
60
|
+
method: string;
|
|
61
|
+
url: string;
|
|
62
|
+
headers: IncomingHttpHeaders;
|
|
63
|
+
};
|
|
64
|
+
request.method = HTTP_METHOD_POST;
|
|
65
|
+
request.url = '/';
|
|
66
|
+
request.headers = { 'content-type': CONTENT_TYPE_JSON };
|
|
67
|
+
if (options.authorization) {
|
|
68
|
+
request.headers.authorization = options.authorization;
|
|
69
|
+
}
|
|
70
|
+
(request as unknown as PassThrough).end(JSON.stringify(options.body));
|
|
71
|
+
return request;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createExecutionContext() {
|
|
75
|
+
return {
|
|
76
|
+
run_id: 'run-wu-2731',
|
|
77
|
+
task_id: 'WU-2731',
|
|
78
|
+
session_id: 'session-wu-2731',
|
|
79
|
+
allowed_scopes: [{ type: 'path' as const, pattern: '**', access: 'read' as const }],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function issuePhoneToken(): string {
|
|
84
|
+
const identity = issueControlPlaneIdentity(
|
|
85
|
+
{
|
|
86
|
+
workspace_id: WORKSPACE_ID,
|
|
87
|
+
org_id: 'org-wu-2731',
|
|
88
|
+
agent_id: 'agent-wu-2731',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
profile: 'phone',
|
|
92
|
+
profiles: {
|
|
93
|
+
phone: {
|
|
94
|
+
ttl_seconds: 60 * 60 * 24,
|
|
95
|
+
scopes: ['tool:*'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
subject: PHONE_SUBJECT,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
return identity.token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('WU-2731: tool-api populates authoritative from from phone token subject', () => {
|
|
105
|
+
it('writes metadata.from = token subject on the execution context', async () => {
|
|
106
|
+
const captured: Array<{ input: unknown; ctx: unknown }> = [];
|
|
107
|
+
const runtime = {
|
|
108
|
+
executeTool: vi.fn(async (_name: string, input: unknown, ctx: unknown) => {
|
|
109
|
+
captured.push({ input, ctx });
|
|
110
|
+
return { success: true, data: {} };
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
const router = createToolApiRouter(runtime as never, {
|
|
114
|
+
allowlistedTools: [TOOL_NAME],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const request = createRequest({
|
|
118
|
+
body: { input: { topic: 'test' }, context: createExecutionContext() },
|
|
119
|
+
authorization: `Bearer ${issuePhoneToken()}`,
|
|
120
|
+
});
|
|
121
|
+
const response = new MockResponse();
|
|
122
|
+
|
|
123
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
124
|
+
TOOL_NAME,
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
expect(response.statusCode).toBe(HTTP_STATUS_OK);
|
|
128
|
+
expect(captured).toHaveLength(1);
|
|
129
|
+
const ctx = captured[0].ctx as { metadata?: Record<string, unknown> };
|
|
130
|
+
expect(ctx.metadata?.[METADATA_KEY_FROM]).toBe(PHONE_SUBJECT);
|
|
131
|
+
expect(ctx.metadata?.[METADATA_KEY_FROM_SOURCE]).toBe('token');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('WU-2731: tool-api ignores body-supplied from field (spoof rejection)', () => {
|
|
136
|
+
it('strips context.from and context.metadata.from; authoritative from always wins', async () => {
|
|
137
|
+
const captured: Array<{ input: unknown; ctx: unknown }> = [];
|
|
138
|
+
const runtime = {
|
|
139
|
+
executeTool: vi.fn(async (_name: string, input: unknown, ctx: unknown) => {
|
|
140
|
+
captured.push({ input, ctx });
|
|
141
|
+
return { success: true, data: {} };
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
const router = createToolApiRouter(runtime as never, {
|
|
145
|
+
allowlistedTools: [TOOL_NAME],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Attacker supplies both top-level context.from and metadata.from.
|
|
149
|
+
const maliciousContext = {
|
|
150
|
+
...createExecutionContext(),
|
|
151
|
+
from: SPOOFED_SUBJECT,
|
|
152
|
+
metadata: { from: SPOOFED_SUBJECT, trace_id: 'keep-me' },
|
|
153
|
+
};
|
|
154
|
+
const request = createRequest({
|
|
155
|
+
body: { input: {}, context: maliciousContext },
|
|
156
|
+
authorization: `Bearer ${issuePhoneToken()}`,
|
|
157
|
+
});
|
|
158
|
+
const response = new MockResponse();
|
|
159
|
+
|
|
160
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
161
|
+
TOOL_NAME,
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
expect(response.statusCode).toBe(HTTP_STATUS_OK);
|
|
165
|
+
const ctx = captured[0].ctx as {
|
|
166
|
+
from?: unknown;
|
|
167
|
+
metadata?: Record<string, unknown>;
|
|
168
|
+
};
|
|
169
|
+
// Body-supplied from is stripped at top level.
|
|
170
|
+
expect(ctx.from).toBeUndefined();
|
|
171
|
+
// Authoritative value from the token subject replaces spoofed metadata.from.
|
|
172
|
+
expect(ctx.metadata?.[METADATA_KEY_FROM]).toBe(PHONE_SUBJECT);
|
|
173
|
+
expect(ctx.metadata?.[METADATA_KEY_FROM]).not.toBe(SPOOFED_SUBJECT);
|
|
174
|
+
// Unrelated metadata fields survive.
|
|
175
|
+
expect(ctx.metadata?.trace_id).toBe('keep-me');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
//
|
|
4
|
+
// WU-2729 (INIT-060 Phase 2): HTTP tool-api enforcement for the 10
|
|
5
|
+
// remote-callable software-delivery tools.
|
|
6
|
+
//
|
|
7
|
+
// Tools covered (per ADR-013 §3 + WU-2729 spec):
|
|
8
|
+
// gates, gates:docs, lane:suggest, lane:health, plan:create,
|
|
9
|
+
// plan:promote, initiative:create, initiative:add-wu,
|
|
10
|
+
// initiative:status, flow:report
|
|
11
|
+
//
|
|
12
|
+
// Each tool must:
|
|
13
|
+
// - Round-trip via POST /tools/:name (200 on success).
|
|
14
|
+
// - Reject callers whose bearer token lacks the tool:<name> scope (403).
|
|
15
|
+
// - Accept callers with a privileged profile (tool:*).
|
|
16
|
+
//
|
|
17
|
+
// The scope check is delegated to authorizeToolRequest; this test pins
|
|
18
|
+
// the integration so we catch regressions where the allowlist diverges
|
|
19
|
+
// from the scope grammar.
|
|
20
|
+
|
|
21
|
+
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
|
|
22
|
+
import { EventEmitter } from 'node:events';
|
|
23
|
+
import { PassThrough } from 'node:stream';
|
|
24
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
25
|
+
import { createToolApiRouter } from '../tool-api.js';
|
|
26
|
+
import { issueWorkspaceProfileIdentity } from '../auth.js';
|
|
27
|
+
|
|
28
|
+
const HTTP_METHOD = { POST: 'POST' } as const;
|
|
29
|
+
const HTTP_STATUS = { OK: 200, FORBIDDEN: 403 } as const;
|
|
30
|
+
const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
|
|
31
|
+
|
|
32
|
+
const REMOTE_CALLABLE_TOOL_NAMES = [
|
|
33
|
+
'gates',
|
|
34
|
+
'gates:docs',
|
|
35
|
+
'lane:suggest',
|
|
36
|
+
'lane:health',
|
|
37
|
+
'plan:create',
|
|
38
|
+
'plan:promote',
|
|
39
|
+
'initiative:create',
|
|
40
|
+
'initiative:add-wu',
|
|
41
|
+
'initiative:status',
|
|
42
|
+
'flow:report',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
class MockResponse extends EventEmitter {
|
|
46
|
+
statusCode = HTTP_STATUS.OK;
|
|
47
|
+
body = '';
|
|
48
|
+
private readonly headers = new Map<string, string>();
|
|
49
|
+
|
|
50
|
+
setHeader(name: string, value: string | number | readonly string[]): this {
|
|
51
|
+
this.headers.set(name.toLowerCase(), String(value));
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
write(chunk: string | Buffer): boolean {
|
|
56
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
end(chunk?: string | Buffer): this {
|
|
61
|
+
if (chunk !== undefined) {
|
|
62
|
+
this.write(chunk);
|
|
63
|
+
}
|
|
64
|
+
this.emit('finish');
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface CreateRequestOptions {
|
|
70
|
+
method: string;
|
|
71
|
+
body?: unknown;
|
|
72
|
+
authorization?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createRequest(options: CreateRequestOptions): IncomingMessage {
|
|
76
|
+
const request = new PassThrough() as unknown as IncomingMessage & {
|
|
77
|
+
method: string;
|
|
78
|
+
url: string;
|
|
79
|
+
headers: IncomingHttpHeaders;
|
|
80
|
+
};
|
|
81
|
+
request.method = options.method;
|
|
82
|
+
request.url = '/';
|
|
83
|
+
request.headers = { 'content-type': CONTENT_TYPE_JSON };
|
|
84
|
+
if (options.authorization) {
|
|
85
|
+
request.headers.authorization = options.authorization;
|
|
86
|
+
}
|
|
87
|
+
const payload = options.body === undefined ? '' : JSON.stringify(options.body);
|
|
88
|
+
(request as unknown as PassThrough).end(payload);
|
|
89
|
+
return request;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createContext() {
|
|
93
|
+
return {
|
|
94
|
+
run_id: 'run-wu-2729',
|
|
95
|
+
task_id: 'WU-2729',
|
|
96
|
+
session_id: 'session-wu-2729',
|
|
97
|
+
allowed_scopes: [{ type: 'path' as const, pattern: '**', access: 'read' as const }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseJsonBody(body: string): unknown {
|
|
102
|
+
return JSON.parse(body);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('WU-2729: POST /tools/:name round-trips 10 software-delivery tools', () => {
|
|
106
|
+
it.each(REMOTE_CALLABLE_TOOL_NAMES)(
|
|
107
|
+
'POST /tools/%s dispatches through runtime and returns 200 when allowlisted with a legacy opaque bearer token',
|
|
108
|
+
async (toolName) => {
|
|
109
|
+
// WU-2779 (P0 security): POST /tools/:name no longer dispatches when
|
|
110
|
+
// the Authorization header is missing. This round-trip test uses a
|
|
111
|
+
// legacy opaque bearer token, which is still permitted by default
|
|
112
|
+
// (allow_legacy_unscoped_tokens = true) and exercises the same runtime
|
|
113
|
+
// path as the original "no auth header" case.
|
|
114
|
+
const runtime = {
|
|
115
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
116
|
+
};
|
|
117
|
+
const router = createToolApiRouter(runtime as never, {
|
|
118
|
+
allowlistedTools: [...REMOTE_CALLABLE_TOOL_NAMES],
|
|
119
|
+
});
|
|
120
|
+
const request = createRequest({
|
|
121
|
+
method: HTTP_METHOD.POST,
|
|
122
|
+
body: { input: {}, context: createContext() },
|
|
123
|
+
authorization: 'Bearer legacy-opaque-token',
|
|
124
|
+
});
|
|
125
|
+
const response = new MockResponse();
|
|
126
|
+
|
|
127
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
128
|
+
toolName,
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(runtime.executeTool).toHaveBeenCalledWith(toolName, {}, createContext());
|
|
132
|
+
expect(response.statusCode).toBe(HTTP_STATUS.OK);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('WU-2729: POST /tools/:name enforces scope grammar per WU-2640', () => {
|
|
138
|
+
it.each(REMOTE_CALLABLE_TOOL_NAMES)(
|
|
139
|
+
'POST /tools/%s with a mobile-profile token (no matching tool scope) returns 403 missing_scopes',
|
|
140
|
+
async (toolName) => {
|
|
141
|
+
const identity = issueWorkspaceProfileIdentity(
|
|
142
|
+
{
|
|
143
|
+
workspace_id: 'ws-wu-2729',
|
|
144
|
+
org_id: 'org-wu-2729',
|
|
145
|
+
agent_id: 'agent-wu-2729',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
profile: 'mobile',
|
|
149
|
+
workspaceRoot: process.cwd(),
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const runtime = {
|
|
153
|
+
executeTool: vi.fn(async () => ({ success: true, data: {} })),
|
|
154
|
+
};
|
|
155
|
+
const router = createToolApiRouter(runtime as never, {
|
|
156
|
+
allowlistedTools: [...REMOTE_CALLABLE_TOOL_NAMES],
|
|
157
|
+
});
|
|
158
|
+
const request = createRequest({
|
|
159
|
+
method: HTTP_METHOD.POST,
|
|
160
|
+
body: { input: {}, context: createContext() },
|
|
161
|
+
authorization: `Bearer ${identity.token}`,
|
|
162
|
+
});
|
|
163
|
+
const response = new MockResponse();
|
|
164
|
+
|
|
165
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
166
|
+
toolName,
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
expect(runtime.executeTool).not.toHaveBeenCalled();
|
|
170
|
+
expect(response.statusCode).toBe(HTTP_STATUS.FORBIDDEN);
|
|
171
|
+
const body = parseJsonBody(response.body) as { error: string; requested: string };
|
|
172
|
+
expect(body.error).toBe('missing_scopes');
|
|
173
|
+
expect(body.requested).toBe(`tool:${toolName}`);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it.each(REMOTE_CALLABLE_TOOL_NAMES)(
|
|
178
|
+
'POST /tools/%s with a privileged-profile token (tool:*) returns 200',
|
|
179
|
+
async (toolName) => {
|
|
180
|
+
const identity = issueWorkspaceProfileIdentity(
|
|
181
|
+
{
|
|
182
|
+
workspace_id: 'ws-wu-2729',
|
|
183
|
+
org_id: 'org-wu-2729',
|
|
184
|
+
agent_id: 'agent-wu-2729',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
profile: 'privileged',
|
|
188
|
+
workspaceRoot: process.cwd(),
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
const runtime = {
|
|
192
|
+
executeTool: vi.fn(async () => ({ success: true, data: { ok: true } })),
|
|
193
|
+
};
|
|
194
|
+
const router = createToolApiRouter(runtime as never, {
|
|
195
|
+
allowlistedTools: [...REMOTE_CALLABLE_TOOL_NAMES],
|
|
196
|
+
});
|
|
197
|
+
const request = createRequest({
|
|
198
|
+
method: HTTP_METHOD.POST,
|
|
199
|
+
body: { input: {}, context: createContext() },
|
|
200
|
+
authorization: `Bearer ${identity.token}`,
|
|
201
|
+
});
|
|
202
|
+
const response = new MockResponse();
|
|
203
|
+
|
|
204
|
+
await router.handleRequest(request, response as unknown as ServerResponse<IncomingMessage>, [
|
|
205
|
+
toolName,
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
expect(runtime.executeTool).toHaveBeenCalled();
|
|
209
|
+
expect(response.statusCode).toBe(HTTP_STATUS.OK);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
});
|