@agentlip/hub 0.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/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth middleware utilities for HTTP mutations + WebSocket token validation.
|
|
3
|
+
*
|
|
4
|
+
* Security requirements:
|
|
5
|
+
* - All token comparisons use constant-time comparison (prevent timing attacks)
|
|
6
|
+
* - Tokens are NEVER echoed in error responses or logs
|
|
7
|
+
* - Generic error messages that don't leak token info
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { constantTimeEqual } from "./authToken";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse Bearer token from Authorization header.
|
|
14
|
+
* Returns the token string if valid "Bearer <token>" format, null otherwise.
|
|
15
|
+
*
|
|
16
|
+
* Does NOT log or include the token in any error state.
|
|
17
|
+
*/
|
|
18
|
+
export function parseBearerToken(req: Request): string | null {
|
|
19
|
+
const authHeader = req.headers.get("Authorization");
|
|
20
|
+
if (!authHeader) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Must be "Bearer <token>" format (case-insensitive "Bearer")
|
|
25
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const token = match[1];
|
|
31
|
+
// Reject empty or whitespace-only tokens
|
|
32
|
+
if (!token || token.trim().length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return token;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type AuthOk = { ok: true };
|
|
40
|
+
export type AuthFailure = { ok: false; response: Response };
|
|
41
|
+
export type AuthResult = AuthOk | AuthFailure;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Require valid Bearer token auth for HTTP requests.
|
|
45
|
+
*
|
|
46
|
+
* Uses constant-time comparison to prevent timing attacks.
|
|
47
|
+
* Returns generic 401 response on failure (no token info leaked).
|
|
48
|
+
*
|
|
49
|
+
* @param req - Incoming HTTP request
|
|
50
|
+
* @param expectedToken - The valid auth token (from server.json)
|
|
51
|
+
* @returns { ok: true } on success, { ok: false, response: Response } on failure
|
|
52
|
+
*/
|
|
53
|
+
export function requireAuth(req: Request, expectedToken: string): AuthResult {
|
|
54
|
+
const providedToken = parseBearerToken(req);
|
|
55
|
+
|
|
56
|
+
if (providedToken === null) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
response: new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
error: "Unauthorized",
|
|
62
|
+
code: "MISSING_AUTH",
|
|
63
|
+
}),
|
|
64
|
+
{
|
|
65
|
+
status: 401,
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Constant-time comparison prevents timing attacks
|
|
73
|
+
if (!constantTimeEqual(providedToken, expectedToken)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
response: new Response(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
error: "Unauthorized",
|
|
79
|
+
code: "INVALID_AUTH",
|
|
80
|
+
}),
|
|
81
|
+
{
|
|
82
|
+
status: 401,
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type WsAuthOk = { ok: true };
|
|
93
|
+
export type WsAuthFailure = { ok: false; closeCode: number; closeReason: string };
|
|
94
|
+
export type WsAuthResult = WsAuthOk | WsAuthFailure;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Require valid token query param for WebSocket connections.
|
|
98
|
+
*
|
|
99
|
+
* WebSocket auth uses ?token=<value> query parameter since
|
|
100
|
+
* browsers cannot set custom headers on WebSocket connections.
|
|
101
|
+
*
|
|
102
|
+
* Uses constant-time comparison to prevent timing attacks.
|
|
103
|
+
* Returns close code/reason on failure (no token info leaked).
|
|
104
|
+
*
|
|
105
|
+
* Close codes:
|
|
106
|
+
* - 4001: Missing token
|
|
107
|
+
* - 4003: Invalid token (Forbidden)
|
|
108
|
+
*
|
|
109
|
+
* @param url - WebSocket connection URL
|
|
110
|
+
* @param expectedToken - The valid auth token (from server.json)
|
|
111
|
+
* @returns { ok: true } on success, { ok: false, closeCode, closeReason } on failure
|
|
112
|
+
*/
|
|
113
|
+
export function requireWsToken(url: URL, expectedToken: string): WsAuthResult {
|
|
114
|
+
const providedToken = url.searchParams.get("token");
|
|
115
|
+
|
|
116
|
+
if (providedToken === null || providedToken.trim().length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
closeCode: 4001,
|
|
120
|
+
closeReason: "Missing authentication token",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Constant-time comparison prevents timing attacks
|
|
125
|
+
if (!constantTimeEqual(providedToken, expectedToken)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
closeCode: 4003,
|
|
129
|
+
closeReason: "Invalid authentication token",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
package/src/authToken.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a cryptographically random auth token.
|
|
5
|
+
*
|
|
6
|
+
* Generates >=128-bit entropy token (32 bytes = 256 bits).
|
|
7
|
+
* Returns 64-character hex string.
|
|
8
|
+
*
|
|
9
|
+
* Never log this token.
|
|
10
|
+
*/
|
|
11
|
+
export function generateAuthToken(): string {
|
|
12
|
+
return randomBytes(32).toString("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Constant-time string comparison helper for auth token validation.
|
|
17
|
+
* Prevents timing attacks.
|
|
18
|
+
*
|
|
19
|
+
* Returns true if strings are equal, false otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
22
|
+
if (a.length !== b.length) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let result = 0;
|
|
27
|
+
for (let i = 0; i < a.length; i++) {
|
|
28
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result === 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation and size limit utilities for HTTP and WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* Provides safe JSON body parsing with:
|
|
5
|
+
* - Configurable byte limits
|
|
6
|
+
* - Proper error handling (no user content echoed in errors)
|
|
7
|
+
* - Validation helpers
|
|
8
|
+
*
|
|
9
|
+
* Size limits from plan (0.1.2 Safe Defaults):
|
|
10
|
+
* - HTTP message content: 64KB
|
|
11
|
+
* - Attachment metadata: 16KB
|
|
12
|
+
* - WS message: 256KB
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Size limit constants (bytes).
|
|
17
|
+
*/
|
|
18
|
+
export const SIZE_LIMITS = {
|
|
19
|
+
/** Max message content: 64KB */
|
|
20
|
+
MESSAGE_BODY: 64 * 1024,
|
|
21
|
+
/** Max attachment metadata: 16KB */
|
|
22
|
+
ATTACHMENT: 16 * 1024,
|
|
23
|
+
/** Max WebSocket message: 256KB */
|
|
24
|
+
WS_MESSAGE: 256 * 1024,
|
|
25
|
+
/** Default HTTP body limit: 64KB */
|
|
26
|
+
DEFAULT_HTTP: 64 * 1024,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for reading JSON body.
|
|
31
|
+
*/
|
|
32
|
+
export interface ReadJsonBodyOptions {
|
|
33
|
+
/** Maximum bytes to accept (default: 64KB) */
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Result of parsing JSON body.
|
|
39
|
+
*/
|
|
40
|
+
export type JsonBodyResult<T = unknown> =
|
|
41
|
+
| { ok: true; data: T }
|
|
42
|
+
| { ok: false; response: Response };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read and parse JSON body from HTTP request with size validation.
|
|
46
|
+
*
|
|
47
|
+
* Security:
|
|
48
|
+
* - Size checked before parsing (DoS protection)
|
|
49
|
+
* - Invalid JSON errors do not echo user content
|
|
50
|
+
* - Generic error messages
|
|
51
|
+
*
|
|
52
|
+
* @param req - HTTP request
|
|
53
|
+
* @param options - Size limit options
|
|
54
|
+
* @returns Parsed JSON or error response
|
|
55
|
+
*/
|
|
56
|
+
export async function readJsonBody<T = unknown>(
|
|
57
|
+
req: Request,
|
|
58
|
+
options: ReadJsonBodyOptions = {}
|
|
59
|
+
): Promise<JsonBodyResult<T>> {
|
|
60
|
+
const maxBytes = options.maxBytes ?? SIZE_LIMITS.DEFAULT_HTTP;
|
|
61
|
+
|
|
62
|
+
// Check Content-Length header first (fast path rejection)
|
|
63
|
+
const contentLength = req.headers.get("Content-Length");
|
|
64
|
+
if (contentLength) {
|
|
65
|
+
const length = parseInt(contentLength, 10);
|
|
66
|
+
if (!isNaN(length) && length > maxBytes) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
response: payloadTooLargeResponse(maxBytes),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check Content-Type
|
|
75
|
+
const contentType = req.headers.get("Content-Type");
|
|
76
|
+
if (!contentType || !contentType.includes("application/json")) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
response: invalidContentTypeResponse(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Read body as ArrayBuffer to check actual size
|
|
85
|
+
const buffer = await req.arrayBuffer();
|
|
86
|
+
|
|
87
|
+
if (buffer.byteLength > maxBytes) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
response: payloadTooLargeResponse(maxBytes),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Decode and parse JSON
|
|
95
|
+
const text = new TextDecoder().decode(buffer);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(text) as T;
|
|
99
|
+
return { ok: true, data };
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
response: invalidJsonResponse(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Body read error (connection reset, etc.)
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
response: bodyReadErrorResponse(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate a WebSocket message size.
|
|
117
|
+
*
|
|
118
|
+
* @param data - Message data (string or binary)
|
|
119
|
+
* @param maxBytes - Maximum allowed bytes (default: 256KB)
|
|
120
|
+
* @returns true if within limit
|
|
121
|
+
*/
|
|
122
|
+
export function validateWsMessageSize(
|
|
123
|
+
data: string | ArrayBuffer | Uint8Array,
|
|
124
|
+
maxBytes: number = SIZE_LIMITS.WS_MESSAGE
|
|
125
|
+
): boolean {
|
|
126
|
+
let size: number;
|
|
127
|
+
|
|
128
|
+
if (typeof data === "string") {
|
|
129
|
+
// For strings, use byte length (UTF-8)
|
|
130
|
+
size = new TextEncoder().encode(data).length;
|
|
131
|
+
} else if (data instanceof ArrayBuffer) {
|
|
132
|
+
size = data.byteLength;
|
|
133
|
+
} else {
|
|
134
|
+
size = data.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return size <= maxBytes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse and validate WebSocket JSON message.
|
|
142
|
+
*
|
|
143
|
+
* @param data - Raw message data
|
|
144
|
+
* @param maxBytes - Maximum allowed bytes
|
|
145
|
+
* @returns Parsed object or null on failure
|
|
146
|
+
*/
|
|
147
|
+
export function parseWsMessage<T = unknown>(
|
|
148
|
+
data: string | ArrayBuffer | Uint8Array,
|
|
149
|
+
maxBytes: number = SIZE_LIMITS.WS_MESSAGE
|
|
150
|
+
): T | null {
|
|
151
|
+
if (!validateWsMessageSize(data, maxBytes)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let text: string;
|
|
156
|
+
if (typeof data === "string") {
|
|
157
|
+
text = data;
|
|
158
|
+
} else if (data instanceof ArrayBuffer) {
|
|
159
|
+
text = new TextDecoder().decode(data);
|
|
160
|
+
} else {
|
|
161
|
+
text = new TextDecoder().decode(data);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(text) as T;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validate that serialized JSON stays within size limit.
|
|
173
|
+
* Useful for validating attachment value_json before insertion.
|
|
174
|
+
*
|
|
175
|
+
* @param value - Value to check
|
|
176
|
+
* @param maxBytes - Maximum serialized size
|
|
177
|
+
* @returns true if within limit
|
|
178
|
+
*/
|
|
179
|
+
export function validateJsonSize(value: unknown, maxBytes: number): boolean {
|
|
180
|
+
try {
|
|
181
|
+
const serialized = JSON.stringify(value);
|
|
182
|
+
return new TextEncoder().encode(serialized).length <= maxBytes;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Error response helpers (generic messages, no user content echoed)
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create 413 Payload Too Large response.
|
|
194
|
+
*/
|
|
195
|
+
export function payloadTooLargeResponse(maxBytes: number): Response {
|
|
196
|
+
return new Response(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
error: `Payload too large (max ${Math.floor(maxBytes / 1024)}KB)`,
|
|
199
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
200
|
+
}),
|
|
201
|
+
{
|
|
202
|
+
status: 413,
|
|
203
|
+
headers: { "Content-Type": "application/json" },
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Create 400 Bad Request response for invalid JSON.
|
|
210
|
+
*/
|
|
211
|
+
export function invalidJsonResponse(): Response {
|
|
212
|
+
return new Response(
|
|
213
|
+
JSON.stringify({
|
|
214
|
+
error: "Invalid JSON",
|
|
215
|
+
code: "INVALID_INPUT",
|
|
216
|
+
}),
|
|
217
|
+
{
|
|
218
|
+
status: 400,
|
|
219
|
+
headers: { "Content-Type": "application/json" },
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Create 415 Unsupported Media Type response.
|
|
226
|
+
*/
|
|
227
|
+
export function invalidContentTypeResponse(): Response {
|
|
228
|
+
return new Response(
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
error: "Content-Type must be application/json",
|
|
231
|
+
code: "INVALID_INPUT",
|
|
232
|
+
}),
|
|
233
|
+
{
|
|
234
|
+
status: 415,
|
|
235
|
+
headers: { "Content-Type": "application/json" },
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create 400 Bad Request response for body read errors.
|
|
242
|
+
*/
|
|
243
|
+
export function bodyReadErrorResponse(): Response {
|
|
244
|
+
return new Response(
|
|
245
|
+
JSON.stringify({
|
|
246
|
+
error: "Failed to read request body",
|
|
247
|
+
code: "INVALID_INPUT",
|
|
248
|
+
}),
|
|
249
|
+
{
|
|
250
|
+
status: 400,
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Create generic 400 Bad Request response for validation errors.
|
|
258
|
+
*
|
|
259
|
+
* @param message - Error message (should not contain user input)
|
|
260
|
+
*/
|
|
261
|
+
export function validationErrorResponse(message: string): Response {
|
|
262
|
+
return new Response(
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
error: message,
|
|
265
|
+
code: "INVALID_INPUT",
|
|
266
|
+
}),
|
|
267
|
+
{
|
|
268
|
+
status: 400,
|
|
269
|
+
headers: { "Content-Type": "application/json" },
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace config loader and schema validation
|
|
3
|
+
*
|
|
4
|
+
* Security requirements:
|
|
5
|
+
* - Only load agentlip.config.ts from workspace root (never traverse upward)
|
|
6
|
+
* - Validate plugin module paths to prevent path traversal
|
|
7
|
+
* - Return null for missing config (optional file)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join, resolve, relative, normalize } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Plugin configuration
|
|
15
|
+
*/
|
|
16
|
+
export interface PluginConfig {
|
|
17
|
+
name: string;
|
|
18
|
+
type: "linkifier" | "extractor";
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** Path to custom plugin module (relative to workspace root or absolute). Default: built-in */
|
|
21
|
+
module?: string;
|
|
22
|
+
/** Plugin-specific configuration */
|
|
23
|
+
config?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Workspace configuration schema
|
|
28
|
+
*/
|
|
29
|
+
export interface WorkspaceConfig {
|
|
30
|
+
plugins?: PluginConfig[];
|
|
31
|
+
rateLimits?: {
|
|
32
|
+
perConnection?: number;
|
|
33
|
+
global?: number;
|
|
34
|
+
};
|
|
35
|
+
limits?: {
|
|
36
|
+
maxMessageSize?: number;
|
|
37
|
+
maxAttachmentSize?: number;
|
|
38
|
+
maxWsMessageSize?: number;
|
|
39
|
+
maxWsConnections?: number;
|
|
40
|
+
maxWsQueueSize?: number;
|
|
41
|
+
maxEventReplayBatch?: number;
|
|
42
|
+
};
|
|
43
|
+
pluginDefaults?: {
|
|
44
|
+
timeout?: number;
|
|
45
|
+
memoryLimit?: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Result of config loading
|
|
51
|
+
*/
|
|
52
|
+
export interface LoadConfigResult {
|
|
53
|
+
config: WorkspaceConfig;
|
|
54
|
+
/** Absolute path to config file (if loaded) */
|
|
55
|
+
configPath?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that a plugin module path does not escape workspace root.
|
|
60
|
+
*
|
|
61
|
+
* Security: prevents path traversal attacks via plugin.module field.
|
|
62
|
+
*
|
|
63
|
+
* @param modulePath - Plugin module path (relative or absolute)
|
|
64
|
+
* @param workspaceRoot - Workspace root directory (absolute)
|
|
65
|
+
* @returns Absolute path to module if valid
|
|
66
|
+
* @throws Error if path escapes workspace root
|
|
67
|
+
*/
|
|
68
|
+
export function validatePluginModulePath(
|
|
69
|
+
modulePath: string,
|
|
70
|
+
workspaceRoot: string
|
|
71
|
+
): string {
|
|
72
|
+
const absWorkspaceRoot = resolve(workspaceRoot);
|
|
73
|
+
|
|
74
|
+
// Resolve module path relative to workspace root (if relative)
|
|
75
|
+
const absModulePath = resolve(absWorkspaceRoot, modulePath);
|
|
76
|
+
|
|
77
|
+
// Normalize paths to handle '..' and '.' components
|
|
78
|
+
const normalizedWorkspaceRoot = normalize(absWorkspaceRoot);
|
|
79
|
+
const normalizedModulePath = normalize(absModulePath);
|
|
80
|
+
|
|
81
|
+
// Check that resolved path is within workspace root
|
|
82
|
+
const rel = relative(normalizedWorkspaceRoot, normalizedModulePath);
|
|
83
|
+
|
|
84
|
+
// relative() returns a path that:
|
|
85
|
+
// - starts with '..' if target is outside source
|
|
86
|
+
// - is empty string if paths are identical
|
|
87
|
+
// - is a relative path within if target is inside source
|
|
88
|
+
|
|
89
|
+
if (rel.startsWith("..") || resolve(normalizedWorkspaceRoot, rel) !== normalizedModulePath) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Plugin module path escapes workspace root: ${modulePath} ` +
|
|
92
|
+
`(resolves to ${normalizedModulePath}, workspace: ${normalizedWorkspaceRoot})`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return normalizedModulePath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate workspace config schema.
|
|
101
|
+
*
|
|
102
|
+
* Performs basic structural validation and security checks.
|
|
103
|
+
*
|
|
104
|
+
* @param config - Config object to validate
|
|
105
|
+
* @param workspaceRoot - Workspace root for plugin path validation
|
|
106
|
+
* @throws Error if validation fails
|
|
107
|
+
*/
|
|
108
|
+
export function validateWorkspaceConfig(
|
|
109
|
+
config: unknown,
|
|
110
|
+
workspaceRoot: string
|
|
111
|
+
): asserts config is WorkspaceConfig {
|
|
112
|
+
if (config === null || typeof config !== "object") {
|
|
113
|
+
throw new Error("Config must be an object");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const cfg = config as Record<string, unknown>;
|
|
117
|
+
|
|
118
|
+
// Validate plugins array (if present)
|
|
119
|
+
if (cfg.plugins !== undefined) {
|
|
120
|
+
if (!Array.isArray(cfg.plugins)) {
|
|
121
|
+
throw new Error("plugins must be an array");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [idx, plugin] of cfg.plugins.entries()) {
|
|
125
|
+
if (plugin === null || typeof plugin !== "object") {
|
|
126
|
+
throw new Error(`plugins[${idx}] must be an object`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const p = plugin as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
// Required fields
|
|
132
|
+
if (typeof p.name !== "string" || p.name.length === 0) {
|
|
133
|
+
throw new Error(`plugins[${idx}].name must be a non-empty string`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (p.type !== "linkifier" && p.type !== "extractor") {
|
|
137
|
+
throw new Error(`plugins[${idx}].type must be "linkifier" or "extractor"`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof p.enabled !== "boolean") {
|
|
141
|
+
throw new Error(`plugins[${idx}].enabled must be a boolean`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate module path (if provided)
|
|
145
|
+
if (p.module !== undefined) {
|
|
146
|
+
if (typeof p.module !== "string") {
|
|
147
|
+
throw new Error(`plugins[${idx}].module must be a string`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Security: validate path does not escape workspace
|
|
151
|
+
try {
|
|
152
|
+
validatePluginModulePath(p.module, workspaceRoot);
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
throw new Error(`plugins[${idx}].module: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate config (if provided)
|
|
159
|
+
if (p.config !== undefined) {
|
|
160
|
+
if (p.config === null || typeof p.config !== "object" || Array.isArray(p.config)) {
|
|
161
|
+
throw new Error(`plugins[${idx}].config must be an object`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Validate rateLimits (if present)
|
|
168
|
+
if (cfg.rateLimits !== undefined) {
|
|
169
|
+
if (cfg.rateLimits === null || typeof cfg.rateLimits !== "object") {
|
|
170
|
+
throw new Error("rateLimits must be an object");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rl = cfg.rateLimits as Record<string, unknown>;
|
|
174
|
+
|
|
175
|
+
if (rl.perConnection !== undefined && typeof rl.perConnection !== "number") {
|
|
176
|
+
throw new Error("rateLimits.perConnection must be a number");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (rl.global !== undefined && typeof rl.global !== "number") {
|
|
180
|
+
throw new Error("rateLimits.global must be a number");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate limits (if present)
|
|
185
|
+
if (cfg.limits !== undefined) {
|
|
186
|
+
if (cfg.limits === null || typeof cfg.limits !== "object") {
|
|
187
|
+
throw new Error("limits must be an object");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const lim = cfg.limits as Record<string, unknown>;
|
|
191
|
+
const limitFields = [
|
|
192
|
+
"maxMessageSize",
|
|
193
|
+
"maxAttachmentSize",
|
|
194
|
+
"maxWsMessageSize",
|
|
195
|
+
"maxWsConnections",
|
|
196
|
+
"maxWsQueueSize",
|
|
197
|
+
"maxEventReplayBatch",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
for (const field of limitFields) {
|
|
201
|
+
if (lim[field] !== undefined && typeof lim[field] !== "number") {
|
|
202
|
+
throw new Error(`limits.${field} must be a number`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Validate pluginDefaults (if present)
|
|
208
|
+
if (cfg.pluginDefaults !== undefined) {
|
|
209
|
+
if (cfg.pluginDefaults === null || typeof cfg.pluginDefaults !== "object") {
|
|
210
|
+
throw new Error("pluginDefaults must be an object");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const pd = cfg.pluginDefaults as Record<string, unknown>;
|
|
214
|
+
|
|
215
|
+
if (pd.timeout !== undefined && typeof pd.timeout !== "number") {
|
|
216
|
+
throw new Error("pluginDefaults.timeout must be a number");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (pd.memoryLimit !== undefined && typeof pd.memoryLimit !== "number") {
|
|
220
|
+
throw new Error("pluginDefaults.memoryLimit must be a number");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Load and validate workspace config from agentlip.config.ts.
|
|
227
|
+
*
|
|
228
|
+
* Security guarantees:
|
|
229
|
+
* - Only loads from workspace root (never traverses upward)
|
|
230
|
+
* - Validates plugin module paths to prevent path traversal
|
|
231
|
+
* - Returns null if config file doesn't exist (optional file)
|
|
232
|
+
*
|
|
233
|
+
* @param workspaceRoot - Absolute path to workspace root directory
|
|
234
|
+
* @returns Config object or null if file doesn't exist
|
|
235
|
+
* @throws Error if config exists but is invalid
|
|
236
|
+
*/
|
|
237
|
+
export async function loadWorkspaceConfig(
|
|
238
|
+
workspaceRoot: string
|
|
239
|
+
): Promise<LoadConfigResult | null> {
|
|
240
|
+
const absWorkspaceRoot = resolve(workspaceRoot);
|
|
241
|
+
const configPath = join(absWorkspaceRoot, "agentlip.config.ts");
|
|
242
|
+
|
|
243
|
+
// Convert to file:// URL for dynamic import
|
|
244
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
245
|
+
|
|
246
|
+
let configModule: unknown;
|
|
247
|
+
try {
|
|
248
|
+
configModule = await import(configUrl);
|
|
249
|
+
} catch (err: any) {
|
|
250
|
+
// File doesn't exist or has syntax errors
|
|
251
|
+
if (err?.code === "ERR_MODULE_NOT_FOUND" || err?.code === "ENOENT") {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Config exists but has errors - propagate
|
|
256
|
+
throw new Error(`Failed to load agentlip.config.ts: ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Extract default export
|
|
260
|
+
const config = (configModule as any)?.default;
|
|
261
|
+
|
|
262
|
+
if (config === undefined) {
|
|
263
|
+
throw new Error("agentlip.config.ts must have a default export");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Validate config schema
|
|
267
|
+
validateWorkspaceConfig(config, absWorkspaceRoot);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
config,
|
|
271
|
+
configPath,
|
|
272
|
+
};
|
|
273
|
+
}
|