thinknagent 0.1.20 → 0.1.23
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 +87 -279
- package/bin/thinknagent.js +24 -1
- package/bin/thinkncollab-mcp.js +387 -0
- package/lib/agent.js +41 -7
- package/lib/alerts.js +12 -10
- package/lib/connect.js +18 -0
- package/lib/daemon.js +5 -4
- package/lib/e2ee.js +45 -0
- package/lib/history.js +143 -0
- package/lib/logwatcher.js +46 -29
- package/lib/metrics.js +71 -4
- package/lib/shell.js +14 -0
- package/package.json +4 -3
- package/install/setup.sh +0 -90
- package/lib/app.js +0 -327
- package/thinknagent.sh +0 -554
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ThinkNCollab Model Context Protocol (MCP) Server
|
|
4
|
+
* Strict Spec Compliance: MCP Protocol Version 2024-11-05 / JSON-RPC 2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const https = require('https');
|
|
10
|
+
const { URL } = require('url');
|
|
11
|
+
|
|
12
|
+
const API_BASE_URL = process.env.THINKNCOLLAB_API_URL || 'http://localhost:3001';
|
|
13
|
+
const API_TOKEN = process.env.THINKNCOLLAB_TOKEN || '';
|
|
14
|
+
const BOARD_ID = process.env.THINKNCOLLAB_BOARD_ID || '';
|
|
15
|
+
const ROOM_ID = process.env.THINKNCOLLAB_ROOM_ID || '';
|
|
16
|
+
|
|
17
|
+
// ── HTTP API Request Helper ───────────────────────────────────────────────────
|
|
18
|
+
function apiRequest(method, endpoint, data = null) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
try {
|
|
21
|
+
const baseObj = new URL(API_BASE_URL);
|
|
22
|
+
|
|
23
|
+
// SECURITY FIX: Ensure endpoint is strictly relative to prevent URL override
|
|
24
|
+
// and exfiltration of API_TOKEN to third-party endpoints.
|
|
25
|
+
const safeEndpoint = endpoint.startsWith('http://') || endpoint.startsWith('https://')
|
|
26
|
+
? new URL(endpoint).pathname + new URL(endpoint).search
|
|
27
|
+
: endpoint;
|
|
28
|
+
|
|
29
|
+
const parsedUrl = new URL(safeEndpoint, baseObj.origin);
|
|
30
|
+
|
|
31
|
+
if (parsedUrl.origin !== baseObj.origin) {
|
|
32
|
+
return reject(new Error(`Security Error: Request origin mismatch (${parsedUrl.origin} vs ${baseObj.origin})`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
36
|
+
const client = isHttps ? https : http;
|
|
37
|
+
|
|
38
|
+
const payload = data ? JSON.stringify(data) : null;
|
|
39
|
+
const headers = {
|
|
40
|
+
'Authorization': `Bearer ${API_TOKEN}`,
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'User-Agent': 'ThinkNCollab-MCP/1.0.0'
|
|
43
|
+
};
|
|
44
|
+
if (payload) {
|
|
45
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const options = {
|
|
49
|
+
hostname: parsedUrl.hostname,
|
|
50
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
51
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
52
|
+
method: method.toUpperCase(),
|
|
53
|
+
headers
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
const req = client.request(options, (res) => {
|
|
58
|
+
let body = '';
|
|
59
|
+
res.setEncoding('utf8');
|
|
60
|
+
res.on('data', chunk => { body += chunk; });
|
|
61
|
+
res.on('end', () => {
|
|
62
|
+
try {
|
|
63
|
+
const json = JSON.parse(body);
|
|
64
|
+
resolve(json);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
resolve({ raw: body, statusCode: res.statusCode });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
req.on('error', (err) => {
|
|
72
|
+
reject(err);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (payload) {
|
|
76
|
+
req.write(payload);
|
|
77
|
+
}
|
|
78
|
+
req.end();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
reject(e);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Strict MCP Tool Definitions (JSON Schema draft-07 compatible) ─────────────
|
|
86
|
+
const TOOLS = [
|
|
87
|
+
{
|
|
88
|
+
name: 'thinkncollab_get_board_state',
|
|
89
|
+
description: 'Get full project backlog, columns, and task list from ThinkNCollab board.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
boardId: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'Board ID (optional if set in environment)'
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
additionalProperties: false
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'thinkncollab_plan_and_create_tasks',
|
|
103
|
+
description: 'Decompose a project or feature into structured tasks and batch-create them on the board.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
boardId: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Board ID (optional if set in environment)'
|
|
110
|
+
},
|
|
111
|
+
tasks: {
|
|
112
|
+
type: 'array',
|
|
113
|
+
description: 'Array of task objects to generate on the board',
|
|
114
|
+
items: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
title: { type: 'string', description: 'Task title' },
|
|
118
|
+
description: { type: 'string', description: 'Markdown technical spec & implementation steps' },
|
|
119
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
120
|
+
category: { type: 'string', description: 'Category e.g. Feature Requests, Security Issues, Bugs' },
|
|
121
|
+
acceptanceCriteria: {
|
|
122
|
+
type: 'array',
|
|
123
|
+
items: { type: 'string' },
|
|
124
|
+
description: 'List of acceptance criteria checklist items'
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
required: ['title']
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
required: ['tasks'],
|
|
132
|
+
additionalProperties: false
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'thinkncollab_create_task',
|
|
137
|
+
description: 'Create a single new task with full technical documentation on the ThinkNCollab board.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
boardId: {
|
|
142
|
+
type: 'string',
|
|
143
|
+
description: 'Board ID (optional if set in environment)'
|
|
144
|
+
},
|
|
145
|
+
title: { type: 'string', description: 'Task title' },
|
|
146
|
+
description: { type: 'string', description: 'Markdown technical specification' },
|
|
147
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
148
|
+
category: { type: 'string', description: 'Task category' },
|
|
149
|
+
acceptanceCriteria: {
|
|
150
|
+
type: 'array',
|
|
151
|
+
items: { type: 'string' },
|
|
152
|
+
description: 'List of acceptance criteria checklist items'
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
required: ['title'],
|
|
156
|
+
additionalProperties: false
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'thinkncollab_get_task_spec',
|
|
161
|
+
description: 'Read the full markdown specification, acceptance criteria, and comments of a task.',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
taskId: { type: 'string', description: 'Task ID' }
|
|
166
|
+
},
|
|
167
|
+
required: ['taskId'],
|
|
168
|
+
additionalProperties: false
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'thinkncollab_update_task_spec',
|
|
173
|
+
description: 'Update the technical documentation, description, or acceptance criteria of a task.',
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
taskId: { type: 'string', description: 'Task ID' },
|
|
178
|
+
description: { type: 'string', description: 'Updated markdown documentation' },
|
|
179
|
+
acceptanceCriteria: {
|
|
180
|
+
type: 'array',
|
|
181
|
+
items: { type: 'string' }
|
|
182
|
+
},
|
|
183
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] }
|
|
184
|
+
},
|
|
185
|
+
required: ['taskId'],
|
|
186
|
+
additionalProperties: false
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'thinkncollab_start_task',
|
|
191
|
+
description: 'Mark a task as in-progress and notify the team that the AI agent is working on it.',
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: {
|
|
195
|
+
taskId: { type: 'string', description: 'Task ID' }
|
|
196
|
+
},
|
|
197
|
+
required: ['taskId'],
|
|
198
|
+
additionalProperties: false
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'thinkncollab_add_comment',
|
|
203
|
+
description: 'Add a progress note, architectural decision, or question to a task.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
taskId: { type: 'string', description: 'Task ID' },
|
|
208
|
+
content: { type: 'string', description: 'Comment text / log' }
|
|
209
|
+
},
|
|
210
|
+
required: ['taskId', 'content'],
|
|
211
|
+
additionalProperties: false
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'thinkncollab_complete_task',
|
|
216
|
+
description: 'Mark a task completed, post the completion verification comment, and trigger auto-tests.',
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {
|
|
220
|
+
taskId: { type: 'string', description: 'Task ID' },
|
|
221
|
+
comment: { type: 'string', description: 'Detailed completion summary of what was implemented and tested' }
|
|
222
|
+
},
|
|
223
|
+
required: ['taskId', 'comment'],
|
|
224
|
+
additionalProperties: false
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'thinkncollab_auto_assign_tasks',
|
|
229
|
+
description: 'Automatically categorize, tag, and distribute board tasks to team members based on domain skills and workload balance.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
boardId: {
|
|
234
|
+
type: 'string',
|
|
235
|
+
description: 'Board ID (optional if set in environment)'
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
additionalProperties: false
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
// ── MCP Tool Execution Handler ────────────────────────────────────────────────
|
|
244
|
+
async function handleToolCall(name, args = {}) {
|
|
245
|
+
const bId = args.boardId || BOARD_ID;
|
|
246
|
+
|
|
247
|
+
switch (name) {
|
|
248
|
+
case 'thinkncollab_get_board_state': {
|
|
249
|
+
if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
|
|
250
|
+
return await apiRequest('GET', `/boards/${bId}/api/state`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case 'thinkncollab_plan_and_create_tasks': {
|
|
254
|
+
if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
|
|
255
|
+
return await apiRequest('POST', `/boards/${bId}/api/plan`, { tasks: args.tasks });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'thinkncollab_create_task': {
|
|
259
|
+
if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
|
|
260
|
+
return await apiRequest('POST', `/boards/${bId}/api/tasks/create`, args);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'thinkncollab_auto_assign_tasks': {
|
|
264
|
+
if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
|
|
265
|
+
return await apiRequest('POST', `/boards/${bId}/api/auto-assign-all`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'thinkncollab_get_task_spec': {
|
|
269
|
+
return await apiRequest('GET', `/tasks/${args.taskId}/api/spec`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case 'thinkncollab_update_task_spec': {
|
|
273
|
+
return await apiRequest('PUT', `/tasks/${args.taskId}/api/spec`, args);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'thinkncollab_start_task': {
|
|
277
|
+
return await apiRequest('POST', `/tasks/${args.taskId}/api/start`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'thinkncollab_add_comment': {
|
|
281
|
+
return await apiRequest('POST', `/tasks/${args.taskId}/api/comment`, { content: args.content });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'thinkncollab_complete_task': {
|
|
285
|
+
return await apiRequest('POST', `/tasks/${args.taskId}/api/complete`, { comment: args.comment });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
default:
|
|
289
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── JSON-RPC 2.0 Response Dispatcher ──────────────────────────────────────────
|
|
294
|
+
function sendResult(id, result) {
|
|
295
|
+
if (id === null || id === undefined) return;
|
|
296
|
+
const res = { jsonrpc: '2.0', id, result };
|
|
297
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sendError(id, code, message) {
|
|
301
|
+
if (id === null || id === undefined) return;
|
|
302
|
+
const res = { jsonrpc: '2.0', id, error: { code, message } };
|
|
303
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Stdio Stream Listener ─────────────────────────────────────────────────────
|
|
307
|
+
const rl = readline.createInterface({
|
|
308
|
+
input: process.stdin,
|
|
309
|
+
output: process.stdout,
|
|
310
|
+
terminal: false
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
rl.on('line', async (line) => {
|
|
314
|
+
const trimmed = line.trim();
|
|
315
|
+
if (!trimmed) return;
|
|
316
|
+
|
|
317
|
+
let msg;
|
|
318
|
+
try {
|
|
319
|
+
msg = JSON.parse(trimmed);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
sendError(null, -32700, 'Parse error');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const { id, method, params } = msg;
|
|
326
|
+
|
|
327
|
+
// Handle Notifications (No id -> Never respond in JSON-RPC 2.0)
|
|
328
|
+
if (id === undefined || id === null) {
|
|
329
|
+
if (method === 'notifications/initialized' || method === 'initialized') {
|
|
330
|
+
process.stderr.write('[MCP] Client initialized successfully.\n');
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Handle Requests
|
|
336
|
+
try {
|
|
337
|
+
if (method === 'initialize') {
|
|
338
|
+
sendResult(id, {
|
|
339
|
+
protocolVersion: '2024-11-05',
|
|
340
|
+
capabilities: {
|
|
341
|
+
tools: {
|
|
342
|
+
listChanged: false
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
serverInfo: {
|
|
346
|
+
name: 'thinkncollab',
|
|
347
|
+
version: '1.0.0'
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
} else if (method === 'tools/list') {
|
|
351
|
+
sendResult(id, {
|
|
352
|
+
tools: TOOLS
|
|
353
|
+
});
|
|
354
|
+
} else if (method === 'tools/call') {
|
|
355
|
+
const toolName = params?.name;
|
|
356
|
+
const toolArgs = params?.arguments || {};
|
|
357
|
+
try {
|
|
358
|
+
const data = await handleToolCall(toolName, toolArgs);
|
|
359
|
+
sendResult(id, {
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: JSON.stringify(data, null, 2)
|
|
364
|
+
}
|
|
365
|
+
],
|
|
366
|
+
isError: false
|
|
367
|
+
});
|
|
368
|
+
} catch (callErr) {
|
|
369
|
+
sendResult(id, {
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: 'text',
|
|
373
|
+
text: `Tool error (${toolName}): ${callErr.message}`
|
|
374
|
+
}
|
|
375
|
+
],
|
|
376
|
+
isError: true
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
} else if (method === 'ping') {
|
|
380
|
+
sendResult(id, {});
|
|
381
|
+
} else {
|
|
382
|
+
sendError(id, -32601, `Method not found: ${method}`);
|
|
383
|
+
}
|
|
384
|
+
} catch (handlerErr) {
|
|
385
|
+
sendError(id, -32603, `Internal error: ${handlerErr.message}`);
|
|
386
|
+
}
|
|
387
|
+
});
|
package/lib/agent.js
CHANGED
|
@@ -6,34 +6,42 @@ const MetricsPoller = require('./metrics');
|
|
|
6
6
|
const LogWatcher = require('./logwatcher');
|
|
7
7
|
const AlertEngine = require('./alerts');
|
|
8
8
|
const ShellBridge = require('./shell');
|
|
9
|
+
const HistoryManager = require('./history');
|
|
9
10
|
const store = require('./store');
|
|
10
11
|
const chokidar = require('chokidar');
|
|
11
12
|
const fs = require('fs');
|
|
12
13
|
|
|
13
|
-
// allowed base dirs for log streaming
|
|
14
|
-
//
|
|
14
|
+
// allowed base dirs for log streaming — only specific, non-writable-by-others paths
|
|
15
|
+
// SECURITY FIX: Removed /tmp (world-writable — attacker can create logs there and stream them)
|
|
16
|
+
// Removed /root (only needed for root-running deployments — overly broad)
|
|
15
17
|
const LOG_PATH_ALLOWLIST = [
|
|
16
18
|
'/var/log',
|
|
17
19
|
'/home',
|
|
18
|
-
'/root',
|
|
19
|
-
'/tmp',
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
function isSafeLogPath(logPath) {
|
|
23
23
|
const resolved = path.resolve(logPath);
|
|
24
24
|
|
|
25
|
-
//
|
|
25
|
+
// Must be a .log file (reject binary or unknown extensions)
|
|
26
|
+
if (!resolved.endsWith('.log') && !resolved.endsWith('.txt') && !resolved.endsWith('.out')) {
|
|
27
|
+
console.warn(`[agent] Rejected log path (not a .log/.txt/.out file): ${resolved}`);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// path traversal check — resolved path must be inside allowlist
|
|
26
32
|
const allowed = LOG_PATH_ALLOWLIST.some(base => resolved.startsWith(base + path.sep) || resolved === base);
|
|
27
33
|
if (!allowed) {
|
|
28
34
|
console.warn(`[agent] Rejected log path (not in allowlist): ${resolved}`);
|
|
29
35
|
return false;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
// sensitive files blocklist
|
|
38
|
+
// sensitive files / directories blocklist
|
|
33
39
|
const BLOCKED = [
|
|
34
40
|
'/etc/passwd', '/etc/shadow', '/etc/sudoers',
|
|
35
41
|
'.ssh', '.gnupg', '.aws', '.env',
|
|
36
42
|
'id_rsa', 'id_ed25519', 'authorized_keys',
|
|
43
|
+
'.thinknagent', '.thinkncollab', 'config.json',
|
|
44
|
+
'session.json', '.npmrc', '.netrc',
|
|
37
45
|
];
|
|
38
46
|
const blocked = BLOCKED.some(b => resolved.includes(b));
|
|
39
47
|
if (blocked) {
|
|
@@ -44,6 +52,7 @@ function isSafeLogPath(logPath) {
|
|
|
44
52
|
return true;
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
|
|
47
56
|
function validateRules(rules) {
|
|
48
57
|
if (!Array.isArray(rules)) return [];
|
|
49
58
|
return rules.filter(r =>
|
|
@@ -71,11 +80,16 @@ class Agent {
|
|
|
71
80
|
});
|
|
72
81
|
|
|
73
82
|
this.alerts.conn = this.conn;
|
|
83
|
+
this.history = new HistoryManager();
|
|
74
84
|
|
|
75
85
|
this.metrics = new MetricsPoller({
|
|
76
86
|
connection: this.conn,
|
|
77
87
|
gpu: cfg.gpu || false,
|
|
78
|
-
|
|
88
|
+
interval: cfg.interval || 1000,
|
|
89
|
+
onMetricsEmit: (payload) => {
|
|
90
|
+
this.alerts.evaluate(payload);
|
|
91
|
+
this.history.recordSnapshot(payload);
|
|
92
|
+
},
|
|
79
93
|
});
|
|
80
94
|
|
|
81
95
|
this.logs = new LogWatcher({
|
|
@@ -108,6 +122,26 @@ class Agent {
|
|
|
108
122
|
this.metrics.pollNow();
|
|
109
123
|
});
|
|
110
124
|
|
|
125
|
+
this.conn.socket.on('agent:get_history', ({ hours, requestId }) => {
|
|
126
|
+
const targetHours = typeof hours === 'number' ? hours : 72;
|
|
127
|
+
console.log(`[agent] Historical metrics requested (${targetHours}h)`);
|
|
128
|
+
const historyData = this.history.getHistory(targetHours);
|
|
129
|
+
this.conn.socket.emit('agent:history_data', {
|
|
130
|
+
requestId,
|
|
131
|
+
hours: targetHours,
|
|
132
|
+
history: historyData
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this.conn.socket.on('agent:send_logs', () => {
|
|
137
|
+
console.log('[agent] Logs refresh requested');
|
|
138
|
+
if (this.logs && this.logs.logPaths) {
|
|
139
|
+
for (const p of this.logs.logPaths) {
|
|
140
|
+
this.logs._sendTail(p);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
111
145
|
this.conn.socket.on('agent:logs_updated', ({ logs }) => {
|
|
112
146
|
if (!Array.isArray(logs)) return;
|
|
113
147
|
|
package/lib/alerts.js
CHANGED
|
@@ -44,7 +44,7 @@ class AlertEngine {
|
|
|
44
44
|
const elapsed = (now - state.firstTriggeredAt) / 1000; // seconds
|
|
45
45
|
if (elapsed >= rule.for && !state.fired) {
|
|
46
46
|
state.fired = true;
|
|
47
|
-
this._fire(rule, val);
|
|
47
|
+
this._fire(rule, val, metrics);
|
|
48
48
|
}
|
|
49
49
|
} else {
|
|
50
50
|
// Condition cleared
|
|
@@ -57,17 +57,19 @@ class AlertEngine {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
_fire(rule, currentValue) {
|
|
60
|
+
_fire(rule, currentValue, metrics = null) {
|
|
61
61
|
console.warn(`[alerts] FIRING: ${rule.id} — ${rule.metric} ${rule.op} ${rule.value} (current: ${currentValue})`);
|
|
62
62
|
this.conn.emit('agent:alert', {
|
|
63
|
-
id:
|
|
64
|
-
metric:
|
|
65
|
-
op:
|
|
66
|
-
threshold:
|
|
67
|
-
current:
|
|
68
|
-
severity:
|
|
69
|
-
status:
|
|
70
|
-
firedAt:
|
|
63
|
+
id: rule.id,
|
|
64
|
+
metric: rule.metric,
|
|
65
|
+
op: rule.op,
|
|
66
|
+
threshold: rule.value,
|
|
67
|
+
current: currentValue,
|
|
68
|
+
severity: rule.severity || 'warning',
|
|
69
|
+
status: 'active',
|
|
70
|
+
firedAt: Date.now(),
|
|
71
|
+
diagnostics: metrics?.diagnostics || null,
|
|
72
|
+
topProcesses: metrics?.processes?.top || [],
|
|
71
73
|
});
|
|
72
74
|
}
|
|
73
75
|
|
package/lib/connect.js
CHANGED
|
@@ -143,6 +143,24 @@ s.on('agent:approved', ({ agentToken, role, roomId }) => {
|
|
|
143
143
|
process.exit(0);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
// ── Periodic HMAC Challenge-Response Zero-Trust Protocol ───────────────────
|
|
147
|
+
s.on('agent:auth_challenge', ({ challenge, ts }) => {
|
|
148
|
+
const cfg = store.read();
|
|
149
|
+
const token = cfg.agentToken || this.agentToken;
|
|
150
|
+
const crypto = require('crypto');
|
|
151
|
+
if (!token) return;
|
|
152
|
+
|
|
153
|
+
const raw = `${challenge}:${ts}:${cfg.agentId}`;
|
|
154
|
+
const signature = crypto.createHmac('sha256', token).update(raw).digest('hex');
|
|
155
|
+
|
|
156
|
+
s.emit('agent:auth_challenge_response', {
|
|
157
|
+
challenge,
|
|
158
|
+
ts,
|
|
159
|
+
signature,
|
|
160
|
+
agentId: cfg.agentId
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
146
164
|
s.on('connect_error', (err) => {
|
|
147
165
|
console.error(`[thinknagent] Connection error: ${err.message}`);
|
|
148
166
|
});
|
package/lib/daemon.js
CHANGED
|
@@ -14,7 +14,7 @@ const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
|
14
14
|
class DaemonManager {
|
|
15
15
|
constructor() {
|
|
16
16
|
if (!fs.existsSync(CONFIG_DIR)) {
|
|
17
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -26,8 +26,8 @@ class DaemonManager {
|
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
29
|
+
const supervisorPath = path.resolve(__dirname, 'supervisor.js');
|
|
30
|
+
const logFd = fs.openSync(LOG_FILE, 'a', 0o600);
|
|
31
31
|
|
|
32
32
|
// Spawn detached supervisor process
|
|
33
33
|
const child = spawn(process.execPath, [supervisorPath], {
|
|
@@ -36,7 +36,8 @@ class DaemonManager {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
child.unref();
|
|
39
|
-
fs.writeFileSync(PID_FILE, String(child.pid), 'utf8');
|
|
39
|
+
fs.writeFileSync(PID_FILE, String(child.pid), { encoding: 'utf8', mode: 0o600 });
|
|
40
|
+
|
|
40
41
|
|
|
41
42
|
console.log(chalk.green('\n ✔ ThinkNCollab Agent Daemon started with Auto-Restart!'));
|
|
42
43
|
console.log(chalk.gray(' ─────────────────────────────────────────────'));
|
package/lib/e2ee.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
End-to-End Encryption (AES-256-GCM) for ThinkNCollab Agent
|
|
7
|
+
Matches browser-side WebCrypto implementation in e2ee-vault.js
|
|
8
|
+
|
|
9
|
+
SECURITY FIX: Key derivation upgraded from SHA-256 (single pass, brute-forceable)
|
|
10
|
+
to PBKDF2-SHA256 with 100,000 iterations. The old approach used a predictable seed
|
|
11
|
+
derived from public roomId — an attacker who knows the roomId could derive the key.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function deriveKeySync(roomId, secretSeed) {
|
|
15
|
+
// Use PBKDF2 with a stable per-room salt and 100k iterations
|
|
16
|
+
// secretSeed is the user-provided secret; falls back to a hardened seed if absent
|
|
17
|
+
const password = secretSeed || ('tnc_vault_' + roomId + '_agent_secret');
|
|
18
|
+
const salt = Buffer.from('thinkncollab-e2ee-agent-salt-v2', 'utf8');
|
|
19
|
+
// 100,000 iterations — OWASP recommended minimum for PBKDF2-SHA256
|
|
20
|
+
return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encryptE2EE(plaintext, roomId, secretSeed) {
|
|
24
|
+
if (!plaintext || typeof plaintext !== "string") return plaintext;
|
|
25
|
+
try {
|
|
26
|
+
const key = deriveKeySync(roomId, secretSeed);
|
|
27
|
+
const iv = crypto.randomBytes(12); // 12-byte random IV
|
|
28
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
29
|
+
|
|
30
|
+
const ciphertext = Buffer.concat([
|
|
31
|
+
cipher.update(plaintext, "utf8"),
|
|
32
|
+
cipher.final()
|
|
33
|
+
]);
|
|
34
|
+
const tag = cipher.getAuthTag(); // 16-byte auth tag
|
|
35
|
+
|
|
36
|
+
// Envelope: [12-byte IV] + [16-byte Tag] + [Ciphertext]
|
|
37
|
+
const combined = Buffer.concat([iv, tag, ciphertext]);
|
|
38
|
+
return "e2ee:" + combined.toString("base64");
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return plaintext;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { encryptE2EE };
|
|
45
|
+
|