@hivemagnus/magnus-server 1.1.13 → 2.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/.env.example +37 -0
- package/README.md +36 -0
- package/agent-version.json +1 -1
- package/dist/ai/TOOLS.md +1 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/env-catalog.js +16 -0
- package/dist/cli/migrations.js +22 -0
- package/dist/cli/verify-initialization.js +12 -0
- package/dist/cli.js +28 -0
- package/dist/config/console-path.js +17 -0
- package/dist/config/env.js +32 -5
- package/dist/config/features.js +4 -1
- package/dist/config/oathweave.js +42 -0
- package/dist/config/rehearsal.js +22 -0
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
- package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
- package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
- package/dist/db/read-retry.js +5 -1
- package/dist/db/seed.js +8 -0
- package/dist/index.js +28 -37
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/error-handler.js +10 -10
- package/dist/middleware/oathweave-verifier.js +23 -0
- package/dist/middleware/oathweave.js +115 -0
- package/dist/middleware/rate-limit.js +12 -3
- package/dist/openapi/spec.js +45 -4
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -68
- package/dist/routes/assistant.js +30 -12
- package/dist/routes/cindertrace-response.js +183 -0
- package/dist/routes/console.js +33 -0
- package/dist/routes/cron.js +8 -5
- package/dist/routes/events-stream.js +10 -0
- package/dist/routes/files.js +2 -1
- package/dist/routes/fleet-gateway.js +69 -0
- package/dist/routes/heartbeat.js +1 -6
- package/dist/routes/hermes.js +26 -6
- package/dist/routes/hiveskills.js +116 -0
- package/dist/routes/library.js +3 -2
- package/dist/routes/logs.js +5 -0
- package/dist/routes/metrics.js +4 -3
- package/dist/routes/nodes.js +45 -8
- package/dist/routes/ops.js +24 -6
- package/dist/routes/profiling.js +2 -1
- package/dist/routes/roles.js +17 -9
- package/dist/routes/security-fusion.js +15 -600
- package/dist/routes/stats.js +8 -5
- package/dist/routes/terminal-ticket.js +3 -3
- package/dist/routes/terminal.js +2 -2
- package/dist/routes/webhooks.js +15 -15
- package/dist/sdk/node-api-client.js +23 -1
- package/dist/security/cindertrace-response.js +14 -0
- package/dist/security/native-completion-registry.js +14 -0
- package/dist/server.js +11 -14
- package/dist/services/ai-assistant-service.js +32 -6
- package/dist/services/ai-tools.js +15 -8
- package/dist/services/ardent-scope-enrollment.js +4 -2
- package/dist/services/command-resource-leases.js +2 -4
- package/dist/services/forecast-service.js +23 -8
- package/dist/services/ops-service.js +20 -25
- package/dist/services/prediction-service.js +2 -1
- package/dist/services/resource-enrollment.js +57 -4
- package/dist/services/secret-views.js +72 -0
- package/dist/services/security-export.js +4 -6
- package/dist/services/topology-service.js +2 -1
- package/dist/services/webhook-security.js +10 -1
- package/dist/services/webhook-service.js +14 -6
- package/dist/utils/node-url.js +2 -1
- package/dist/utils/safe-errors.js +130 -0
- package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
- package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
- package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
- package/package.json +7 -3
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
- package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +22 -0
- package/dist/services/security-fusion-actions.js +0 -22
- package/dist/services/security-fusion-evidence-integrity.js +0 -144
- package/dist/services/security-fusion-ingest.js +0 -101
- package/dist/services/security-fusion-outbox.js +0 -185
- package/dist/services/security-fusion-receipt-chain.js +0 -134
- package/dist/services/security-fusion-service.js +0 -751
- package/dist/services/security-fusion-verification.js +0 -50
package/dist/queue/workers.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { ResourceLeaseConflict } from '../services/command-resource-leases.js';
|
|
2
|
-
import { securityFusionLocal } from '../services/security-export.js';
|
|
3
2
|
import { recordMonitorPulse } from '../services/monitor-pulse.js';
|
|
4
3
|
import { Queue, Worker } from 'bullmq';
|
|
4
|
+
import { bullQueueIdentity } from './names.js';
|
|
5
|
+
import { assertLiveExecution } from '../config/rehearsal.js';
|
|
5
6
|
import { addDurableJob } from './requeue.js';
|
|
6
7
|
import { validateRemediationCommandForDispatch } from '../services/remediation-service.js';
|
|
7
8
|
import { reconcileCommands } from '../services/command-lifecycle.js';
|
|
8
|
-
import { reconcileRestartVerification } from '../services/security-fusion-verification.js';
|
|
9
9
|
import { getDb } from '../db/index.js';
|
|
10
10
|
import { NodeApiClient } from '../sdk/index.js';
|
|
11
11
|
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
@@ -14,11 +14,10 @@ import { RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET, getNodeManagerSe
|
|
|
14
14
|
import { safeParse } from '../utils/validate.js';
|
|
15
15
|
import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
|
|
16
16
|
import { incMetric } from '../routes/metrics.js';
|
|
17
|
-
import { securityFusionActionSources } from '../services/security-fusion-actions.js';
|
|
18
17
|
import { verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
19
18
|
// ─── Command execution queue ───
|
|
20
19
|
const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
|
|
21
|
-
const COMMAND_QUEUE =
|
|
20
|
+
const COMMAND_QUEUE = bullQueueIdentity(REDIS_PREFIX, 'commands');
|
|
22
21
|
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379';
|
|
23
22
|
let _commandQueue = null;
|
|
24
23
|
let _commandWorker = null;
|
|
@@ -28,8 +27,10 @@ const COMMAND_EXECUTION_LEASE_MS = 5 * 60 * 1_000;
|
|
|
28
27
|
const NODE_HEARTBEAT_FRESHNESS_MS = Number(process.env['STALE_NODE_THRESHOLD_MS'] ?? 120_000);
|
|
29
28
|
/** Get the singleton command queue instance. */
|
|
30
29
|
export function getCommandQueue() {
|
|
30
|
+
assertLiveExecution();
|
|
31
31
|
if (!_commandQueue) {
|
|
32
|
-
_commandQueue = new Queue(COMMAND_QUEUE, {
|
|
32
|
+
_commandQueue = new Queue(COMMAND_QUEUE.name, {
|
|
33
|
+
prefix: COMMAND_QUEUE.prefix,
|
|
33
34
|
connection: { url: REDIS_URL },
|
|
34
35
|
defaultJobOptions: {
|
|
35
36
|
attempts: 1,
|
|
@@ -69,64 +70,12 @@ export async function enqueueCommand(commandId) {
|
|
|
69
70
|
removeOnFail: { age: 24 * 60 * 60, count: 1_000 },
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Verify agent HMAC-SHA256 signature over command result fields.
|
|
74
|
-
* Returns true if valid, false if tampered. Skips if secret is not configured.
|
|
75
|
-
*/
|
|
76
|
-
async function recordSecurityFusionCommandResult(commandId, succeeded, detail) {
|
|
77
|
-
const db = getDb();
|
|
78
|
-
const outbox = await db.securityFusionCommandOutbox.findUnique({
|
|
79
|
-
where: { remoteCommandId: commandId },
|
|
80
|
-
});
|
|
81
|
-
const action = outbox
|
|
82
|
-
? await db.securityFusionAction.findUnique({ where: { id: outbox.actionId } })
|
|
83
|
-
: null;
|
|
84
|
-
if (action && outbox) {
|
|
85
|
-
if (succeeded && action.kind === 'service_restart') {
|
|
86
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
87
|
-
where: { id: action.id, status: { in: securityFusionActionSources('verifying') } },
|
|
88
|
-
data: { status: 'verifying', resultJson: JSON.stringify({ commandId, ...detail }) },
|
|
89
|
-
});
|
|
90
|
-
if (transitioned.count !== 1)
|
|
91
|
-
return;
|
|
92
|
-
await db.securityFusionCaseActivity.create({
|
|
93
|
-
data: {
|
|
94
|
-
caseId: action.caseId,
|
|
95
|
-
kind: 'containment.restart_verifying',
|
|
96
|
-
actorId: 'system:command-worker',
|
|
97
|
-
detailJson: JSON.stringify({ actionId: action.id, commandId }),
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
const releasing = outbox.operation === 'release';
|
|
103
|
-
const status = succeeded ? (releasing ? 'released' : 'succeeded') : 'failed';
|
|
104
|
-
const transitioned = await db.securityFusionAction.updateMany({
|
|
105
|
-
where: {
|
|
106
|
-
id: action.id,
|
|
107
|
-
status: {
|
|
108
|
-
in: securityFusionActionSources(status),
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
data: { status, resultJson: JSON.stringify({ commandId, ...detail }) },
|
|
112
|
-
});
|
|
113
|
-
if (transitioned.count !== 1)
|
|
114
|
-
return;
|
|
115
|
-
await db.securityFusionCaseActivity.create({
|
|
116
|
-
data: {
|
|
117
|
-
caseId: action.caseId,
|
|
118
|
-
kind: `containment.${status}`,
|
|
119
|
-
actorId: 'system:command-worker',
|
|
120
|
-
detailJson: JSON.stringify({ actionId: action.id, commandId, ...detail }),
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
73
|
/** Start the command execution worker. */
|
|
126
74
|
export function startCommandWorker() {
|
|
75
|
+
assertLiveExecution();
|
|
127
76
|
if (_commandWorker)
|
|
128
77
|
return _commandWorker;
|
|
129
|
-
const worker = new Worker(COMMAND_QUEUE, async (job) => {
|
|
78
|
+
const worker = new Worker(COMMAND_QUEUE.name, async (job) => {
|
|
130
79
|
const db = getDb();
|
|
131
80
|
const { commandId } = job.data;
|
|
132
81
|
const now = new Date();
|
|
@@ -241,11 +190,6 @@ export function startCommandWorker() {
|
|
|
241
190
|
});
|
|
242
191
|
if (!recorded)
|
|
243
192
|
return;
|
|
244
|
-
if (securityFusionLocal())
|
|
245
|
-
await recordSecurityFusionCommandResult(commandId, result.exitCode === 0, {
|
|
246
|
-
exitCode: result.exitCode,
|
|
247
|
-
durationMs: result.durationMs,
|
|
248
|
-
});
|
|
249
193
|
// Emit notification on command failure
|
|
250
194
|
if (result.exitCode !== 0) {
|
|
251
195
|
// Look up node name for the notification
|
|
@@ -287,8 +231,6 @@ export function startCommandWorker() {
|
|
|
287
231
|
});
|
|
288
232
|
if (!failed)
|
|
289
233
|
throw err;
|
|
290
|
-
if (securityFusionLocal())
|
|
291
|
-
await recordSecurityFusionCommandResult(commandId, false, { error: errMsg });
|
|
292
234
|
// Emit notification on worker-level failure
|
|
293
235
|
const node = nodeId ? await db.node.findUnique({
|
|
294
236
|
where: { id: nodeId },
|
|
@@ -308,6 +250,7 @@ export function startCommandWorker() {
|
|
|
308
250
|
}, {
|
|
309
251
|
connection: { url: REDIS_URL },
|
|
310
252
|
concurrency: 10,
|
|
253
|
+
prefix: COMMAND_QUEUE.prefix,
|
|
311
254
|
});
|
|
312
255
|
worker.on('failed', (job, err) => {
|
|
313
256
|
console.error(`[CommandWorker] Job ${job?.id} failed:`, err.message);
|
|
@@ -321,8 +264,6 @@ export function startCommandWorker() {
|
|
|
321
264
|
reconciliationBusy = true;
|
|
322
265
|
try {
|
|
323
266
|
await reconcileCommands();
|
|
324
|
-
if (securityFusionLocal())
|
|
325
|
-
await reconcileRestartVerification();
|
|
326
267
|
recordMonitorPulse('commands');
|
|
327
268
|
}
|
|
328
269
|
catch {
|
package/dist/routes/assistant.js
CHANGED
|
@@ -24,13 +24,25 @@ import { Hono } from 'hono';
|
|
|
24
24
|
import { streamSSE } from 'hono/streaming';
|
|
25
25
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
|
26
26
|
import { HttpError } from '../middleware/error-handler.js';
|
|
27
|
-
import {
|
|
27
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
28
|
+
import { chat, chatStream, listConversations, getConversationMessages, getUnreadAiMessageCount, markAiMessagesRead, markConversationRead, archiveConversation, deleteConversation, getHeartbeatPrompt, updateHeartbeatPrompt, resetHeartbeatPrompt, listOllamaModels, getModelContextLength, getAiSettings, updateAiSettings, getModelProfiles, updateModelProfiles, approveToolCall, rejectToolCall, createConversationShare, forkConversation, getSharedConversation, conversationOwners, getConversationAccess, } from '../services/ai-assistant-service.js';
|
|
28
29
|
import { executeAiTool } from '../services/ai-tools.js';
|
|
29
30
|
import { generateInsights, listPinnedInsights, pinInsight, unpinInsight } from '../services/ai-insights-service.js';
|
|
30
31
|
import { exportConversation } from '../services/ai-export-service.js';
|
|
31
32
|
import { clearScratchpad, listScratchpadEntries } from '../services/ai-scratchpad-service.js';
|
|
32
33
|
export const assistantRouter = new Hono();
|
|
33
34
|
assistantRouter.use('*', authMiddleware);
|
|
35
|
+
const ownersOf = (c) => conversationOwners(c.get('user'));
|
|
36
|
+
/**
|
|
37
|
+
* A conversation belongs to whoever started it. ADMIN gets no exception, because no
|
|
38
|
+
* console feature reads other people's conversations. Someone else's answers 404,
|
|
39
|
+
* so a caller cannot tell it exists.
|
|
40
|
+
*/
|
|
41
|
+
async function requireConversation(c, id, level = 'own') {
|
|
42
|
+
const access = await getConversationAccess(id, ownersOf(c));
|
|
43
|
+
if (!access || (level === 'own' && access !== 'owner'))
|
|
44
|
+
throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
|
|
45
|
+
}
|
|
34
46
|
// ── Send a chat message ──
|
|
35
47
|
assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
36
48
|
const body = await c.req.json();
|
|
@@ -44,6 +56,8 @@ assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
44
56
|
ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}). All fleet data you access is from this control node's database. When the user asks about "the fleet" or "nodes", they mean the nodes managed by this control node. Your tools (like getFleetStatus, getNodeHealth, etc.) query this control node's data directly — they do NOT require a fleet ID parameter.`);
|
|
45
57
|
controllerContext = ctxParts.join('\n');
|
|
46
58
|
}
|
|
59
|
+
if (body.conversationId)
|
|
60
|
+
await requireConversation(c, body.conversationId);
|
|
47
61
|
const user = c.get('user');
|
|
48
62
|
const result = await chat({
|
|
49
63
|
message: body.message,
|
|
@@ -67,28 +81,23 @@ assistantRouter.get('/conversations', async (c) => {
|
|
|
67
81
|
status: c.req.query('status') ?? 'active',
|
|
68
82
|
page: parseInt(c.req.query('page') ?? '1', 10),
|
|
69
83
|
pageSize: parseInt(c.req.query('pageSize') ?? '20', 10),
|
|
84
|
+
owners: ownersOf(c),
|
|
70
85
|
});
|
|
71
86
|
return c.json({ success: true, data: result });
|
|
72
87
|
});
|
|
73
88
|
// ── Get conversation messages ──
|
|
74
89
|
assistantRouter.get('/conversations/:id', async (c) => {
|
|
75
90
|
const id = c.req.param('id');
|
|
91
|
+
await requireConversation(c, id, 'read');
|
|
76
92
|
const result = await getConversationMessages(id, {
|
|
77
93
|
page: parseInt(c.req.query('page') ?? '1', 10),
|
|
78
94
|
pageSize: parseInt(c.req.query('pageSize') ?? '50', 10),
|
|
79
95
|
});
|
|
80
|
-
if (result.total === 0) {
|
|
81
|
-
// Check if conversation exists
|
|
82
|
-
const { getDb } = await import('../db/index.js');
|
|
83
|
-
const db = getDb();
|
|
84
|
-
const conv = await db.aiConversation.findUnique({ where: { id } });
|
|
85
|
-
if (!conv)
|
|
86
|
-
throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
|
|
87
|
-
}
|
|
88
96
|
return c.json({ success: true, data: result });
|
|
89
97
|
});
|
|
90
98
|
assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
91
99
|
const id = c.req.param('id');
|
|
100
|
+
await requireConversation(c, id, 'read');
|
|
92
101
|
const body = (await c.req.json().catch(() => ({})));
|
|
93
102
|
const user = c.get('user');
|
|
94
103
|
try {
|
|
@@ -107,6 +116,7 @@ assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR')
|
|
|
107
116
|
});
|
|
108
117
|
assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
109
118
|
const id = c.req.param('id');
|
|
119
|
+
await requireConversation(c, id);
|
|
110
120
|
const body = (await c.req.json().catch(() => ({})));
|
|
111
121
|
const user = c.get('user');
|
|
112
122
|
try {
|
|
@@ -123,6 +133,7 @@ assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'
|
|
|
123
133
|
}
|
|
124
134
|
});
|
|
125
135
|
assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
|
|
136
|
+
await requireConversation(c, c.req.param('id'), 'read');
|
|
126
137
|
const entries = listScratchpadEntries(c.req.param('id')).map((entry) => ({
|
|
127
138
|
name: entry.name,
|
|
128
139
|
source: entry.source,
|
|
@@ -134,6 +145,7 @@ assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
|
|
|
134
145
|
return c.json({ success: true, data: { entries } });
|
|
135
146
|
});
|
|
136
147
|
assistantRouter.delete('/conversations/:id/scratchpad', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
148
|
+
await requireConversation(c, c.req.param('id'));
|
|
137
149
|
clearScratchpad(c.req.param('id'));
|
|
138
150
|
return c.json({ success: true });
|
|
139
151
|
});
|
|
@@ -147,12 +159,14 @@ assistantRouter.get('/shares/:token', async (c) => {
|
|
|
147
159
|
// ── Mark conversation as read ──
|
|
148
160
|
assistantRouter.post('/conversations/:id/read', async (c) => {
|
|
149
161
|
const id = c.req.param('id');
|
|
162
|
+
await requireConversation(c, id, 'read');
|
|
150
163
|
const count = await markConversationRead(id);
|
|
151
164
|
return c.json({ success: true, data: { marked: count } });
|
|
152
165
|
});
|
|
153
166
|
// ── Archive a conversation ──
|
|
154
167
|
assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
155
168
|
const id = c.req.param('id');
|
|
169
|
+
await requireConversation(c, id);
|
|
156
170
|
try {
|
|
157
171
|
await archiveConversation(id);
|
|
158
172
|
return c.json({ success: true });
|
|
@@ -163,7 +177,7 @@ assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATO
|
|
|
163
177
|
});
|
|
164
178
|
// ── Get unread message count ──
|
|
165
179
|
assistantRouter.get('/unread', async (c) => {
|
|
166
|
-
const count = await getUnreadAiMessageCount();
|
|
180
|
+
const count = await getUnreadAiMessageCount(ownersOf(c));
|
|
167
181
|
return c.json({ success: true, data: { unread: count } });
|
|
168
182
|
});
|
|
169
183
|
// ── Mark specific messages as read ──
|
|
@@ -172,12 +186,13 @@ assistantRouter.post('/messages/read', async (c) => {
|
|
|
172
186
|
if (!body.messageIds?.length) {
|
|
173
187
|
throw new HttpError(400, 'VALIDATION_ERROR', 'messageIds array is required');
|
|
174
188
|
}
|
|
175
|
-
const count = await markAiMessagesRead(body.messageIds);
|
|
189
|
+
const count = await markAiMessagesRead(body.messageIds, ownersOf(c));
|
|
176
190
|
return c.json({ success: true, data: { marked: count } });
|
|
177
191
|
});
|
|
178
192
|
// ── Delete a conversation permanently ──
|
|
179
193
|
assistantRouter.delete('/conversations/:id', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
180
194
|
const id = c.req.param('id');
|
|
195
|
+
await requireConversation(c, id);
|
|
181
196
|
try {
|
|
182
197
|
await deleteConversation(id);
|
|
183
198
|
return c.json({ success: true });
|
|
@@ -236,6 +251,8 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
|
|
|
236
251
|
ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}).`);
|
|
237
252
|
controllerContext = ctxParts.join('\n');
|
|
238
253
|
}
|
|
254
|
+
if (body.conversationId)
|
|
255
|
+
await requireConversation(c, body.conversationId);
|
|
239
256
|
const user = c.get('user');
|
|
240
257
|
return streamSSE(c, async (stream) => {
|
|
241
258
|
try {
|
|
@@ -261,7 +278,7 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
|
|
|
261
278
|
catch (err) {
|
|
262
279
|
await stream.writeSSE({
|
|
263
280
|
event: 'error',
|
|
264
|
-
data: JSON.stringify({ type: 'error', error: err
|
|
281
|
+
data: JSON.stringify({ type: 'error', error: clientErrorMessage(err, 'Stream failed') }),
|
|
265
282
|
});
|
|
266
283
|
}
|
|
267
284
|
});
|
|
@@ -352,6 +369,7 @@ assistantRouter.post('/messages/:id/confirm', requireRole('ADMIN', 'OPERATOR'),
|
|
|
352
369
|
// ── Conversation export ──
|
|
353
370
|
assistantRouter.get('/conversations/:id/export', async (c) => {
|
|
354
371
|
const id = c.req.param('id');
|
|
372
|
+
await requireConversation(c, id, 'read');
|
|
355
373
|
const format = (c.req.query('format') ?? 'markdown');
|
|
356
374
|
if (!['markdown', 'json', 'csv'].includes(format)) {
|
|
357
375
|
throw new HttpError(400, 'VALIDATION_ERROR', 'format must be markdown, json, or csv');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { bridgeSignature, equalSignature } from '../security/cindertrace-response.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { responseRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
7
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
8
|
+
import { getNodeManagerSecret } from '../utils/env.js';
|
|
9
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
10
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
11
|
+
export const cindertraceResponseRouter = new Hono();
|
|
12
|
+
function secret() {
|
|
13
|
+
const value = process.env['CINDERTRACE_RESPONSE_SECRET'] ?? '';
|
|
14
|
+
if (value.length < 32 ||
|
|
15
|
+
value === process.env['API_SECRET'] ||
|
|
16
|
+
value === process.env['NODE_MANAGER_SECRET'])
|
|
17
|
+
throw new HttpError(503, 'RESPONSE_DISABLED', 'Dedicated Cindertrace response credential is required');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
cindertraceResponseRouter.use('*', async (c, next) => {
|
|
21
|
+
const timestamp = c.req.header('x-cindertrace-time') ?? '', nonce = c.req.header('x-cindertrace-nonce') ?? '';
|
|
22
|
+
if (!/^\d{13}$/.test(timestamp) ||
|
|
23
|
+
Math.abs(Date.now() - Number(timestamp)) > 60000 ||
|
|
24
|
+
!/^[a-f0-9]{32}$/.test(nonce))
|
|
25
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Invalid response request');
|
|
26
|
+
const raw = c.req.method === 'GET' ? '' : await c.req.text();
|
|
27
|
+
if (Buffer.byteLength(raw) > 16384 ||
|
|
28
|
+
!equalSignature(c.req.header('x-cindertrace-signature') ?? '', bridgeSignature(secret(), c.req.method, c.req.path, timestamp, nonce, raw)))
|
|
29
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Invalid response request');
|
|
30
|
+
await next();
|
|
31
|
+
// Sign the exact response body and bind it to this request; no source output or credentials are returned.
|
|
32
|
+
const text = await c.res.clone().text();
|
|
33
|
+
c.header('x-cindertrace-signature', bridgeSignature(secret(), 'RESPONSE', c.req.path, timestamp, nonce, text));
|
|
34
|
+
c.header('Cache-Control', 'no-store');
|
|
35
|
+
});
|
|
36
|
+
async function client(nodeId) {
|
|
37
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId } });
|
|
38
|
+
if (!node)
|
|
39
|
+
throw new HttpError(404, 'TARGET_NOT_FOUND', 'Target is unavailable');
|
|
40
|
+
return {
|
|
41
|
+
node,
|
|
42
|
+
api: new NodeApiClient({
|
|
43
|
+
baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
|
|
44
|
+
nodeToken: getNodeManagerSecret(),
|
|
45
|
+
resourceNodeId: node.id,
|
|
46
|
+
resourceActorId: 'service:cindertrace',
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
cindertraceResponseRouter.get('/targets/:id/health', async (c) => {
|
|
51
|
+
const { api } = await client(c.req.param('id'));
|
|
52
|
+
const health = await api.collectHealth();
|
|
53
|
+
return c.json({
|
|
54
|
+
data: {
|
|
55
|
+
services: health.data?.services ?? [],
|
|
56
|
+
wazuh: health.data?.wazuh,
|
|
57
|
+
responseCapable: await api.securityResponseCapable(),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
export function validateResponsePolicy(p, node) {
|
|
62
|
+
if (p.operation === 'release')
|
|
63
|
+
return;
|
|
64
|
+
if (node.status !== 'ONLINE' ||
|
|
65
|
+
!node.lastHeartbeatAt ||
|
|
66
|
+
Date.now() - node.lastHeartbeatAt.getTime() > 120000)
|
|
67
|
+
throw new HttpError(409, 'TARGET_STALE', 'A fresh online target is required');
|
|
68
|
+
if (p.kind === 'service_restart') {
|
|
69
|
+
const allowed = (process.env['CINDERTRACE_RESTART_ALLOWLIST'] ?? '')
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((x) => x.trim());
|
|
72
|
+
if (!p.serviceName || !allowed.includes(p.serviceName))
|
|
73
|
+
throw new HttpError(403, 'RESTART_BLOCKED', 'Service must be explicitly allowlisted');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const tags = JSON.parse(node.tags), metadata = JSON.parse(node.metadata);
|
|
77
|
+
const protectedIds = (process.env['CINDERTRACE_PROTECTED_TARGET_IDS'] ?? '').split(',');
|
|
78
|
+
if (protectedIds.includes(node.id) ||
|
|
79
|
+
tags.some((x) => ['control-plane', 'network-infrastructure'].includes(x)) ||
|
|
80
|
+
metadata['securityIsolationPolicy'] !== 'allow' ||
|
|
81
|
+
metadata['securityIsolationCapable'] !== true ||
|
|
82
|
+
metadata['securityAegisChannelVerified'] !== true)
|
|
83
|
+
throw new HttpError(403, 'ISOLATION_BLOCKED', 'Explicit isolation policy, capability, and verified recovery channel are required');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cindertraceResponseRouter.post('/execute', async (c) => {
|
|
87
|
+
const parsed = responseRequestSchema.safeParse(await c.req.json());
|
|
88
|
+
if (!parsed.success)
|
|
89
|
+
throw new HttpError(400, 'INVALID_RESPONSE', 'Malformed response plan');
|
|
90
|
+
const p = parsed.data, raw = JSON.stringify(p), hash = createHash('sha256').update(raw).digest('hex');
|
|
91
|
+
const db = getDb();
|
|
92
|
+
const prior = await db.cindertraceResponse.findUnique({ where: { id: p.executionId } });
|
|
93
|
+
if (prior) {
|
|
94
|
+
if (prior.requestSha256 !== hash)
|
|
95
|
+
throw new HttpError(409, 'RESPONSE_CONFLICT', 'Execution identity is bound to another plan');
|
|
96
|
+
if (prior.status !== 'completed' || !prior.responseJson)
|
|
97
|
+
throw new HttpError(409, 'RESPONSE_UNKNOWN', 'Inspect the original execution; do not replay it');
|
|
98
|
+
return c.json(JSON.parse(prior.responseJson));
|
|
99
|
+
}
|
|
100
|
+
if (p.expiresAt <= Date.now() || p.expiresAt > Date.now() + 600000)
|
|
101
|
+
throw new HttpError(409, 'APPROVAL_EXPIRED', 'Approval is expired or outside its maximum lifetime');
|
|
102
|
+
const { node, api } = await client(p.nodeId);
|
|
103
|
+
validateResponsePolicy(p, node);
|
|
104
|
+
if (p.operation === 'release') {
|
|
105
|
+
const original = await db.cindertraceResponse.findUnique({
|
|
106
|
+
where: { actionOperation: `${p.actionId}:execute` },
|
|
107
|
+
});
|
|
108
|
+
if (!original ||
|
|
109
|
+
original.nodeId !== p.nodeId ||
|
|
110
|
+
JSON.parse(original.requestJson).kind !== 'host_isolation')
|
|
111
|
+
throw new HttpError(409, 'RELEASE_UNBOUND', 'Release requires an existing isolation execution on this target');
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await db.cindertraceResponse.create({
|
|
115
|
+
data: {
|
|
116
|
+
id: p.executionId,
|
|
117
|
+
actionOperation: `${p.actionId}:${p.operation}`,
|
|
118
|
+
nodeId: p.nodeId,
|
|
119
|
+
requestSha256: hash,
|
|
120
|
+
requestJson: raw,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === 'P2002')
|
|
126
|
+
throw new HttpError(409, 'RESPONSE_ALREADY_CLAIMED', 'Action operation already has an owner');
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
await logAuditEvent({
|
|
130
|
+
action: 'cindertrace.response_claimed',
|
|
131
|
+
actorId: p.approvedBy,
|
|
132
|
+
targetType: 'node',
|
|
133
|
+
targetId: p.nodeId,
|
|
134
|
+
details: {
|
|
135
|
+
actionId: p.actionId,
|
|
136
|
+
executionId: p.executionId,
|
|
137
|
+
planSha256: p.planSha256,
|
|
138
|
+
operation: p.operation,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
const result = await api.securityResponse({
|
|
143
|
+
operation: p.operation,
|
|
144
|
+
kind: p.kind,
|
|
145
|
+
serviceName: p.serviceName,
|
|
146
|
+
isolationMinutes: p.isolationMinutes,
|
|
147
|
+
}, p.executionId);
|
|
148
|
+
if (!result.response.success || !Number.isInteger(result.response.data?.exitCode))
|
|
149
|
+
throw new Error('Response failed');
|
|
150
|
+
const reply = {
|
|
151
|
+
commandId: p.executionId,
|
|
152
|
+
actionId: p.actionId,
|
|
153
|
+
nodeId: p.nodeId,
|
|
154
|
+
operation: p.operation,
|
|
155
|
+
planSha256: p.planSha256,
|
|
156
|
+
exitCode: result.response.data.exitCode,
|
|
157
|
+
receiptSha256: result.evidence.receiptSha256,
|
|
158
|
+
};
|
|
159
|
+
await db.cindertraceResponse.update({
|
|
160
|
+
where: { id: p.executionId },
|
|
161
|
+
data: { status: 'completed', responseJson: JSON.stringify(reply) },
|
|
162
|
+
});
|
|
163
|
+
return c.json(reply);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
await db.cindertraceResponse.update({
|
|
167
|
+
where: { id: p.executionId },
|
|
168
|
+
data: { status: 'unknown' },
|
|
169
|
+
});
|
|
170
|
+
throw new HttpError(409, 'RESPONSE_UNKNOWN', 'No verified completion; inspect the original execution');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
cindertraceResponseRouter.get('/:id', async (c) => {
|
|
174
|
+
const row = await getDb().cindertraceResponse.findUnique({ where: { id: c.req.param('id') } });
|
|
175
|
+
if (!row)
|
|
176
|
+
throw new HttpError(404, 'NOT_FOUND', 'Execution is unavailable');
|
|
177
|
+
return c.json({
|
|
178
|
+
executionId: row.id,
|
|
179
|
+
status: row.status,
|
|
180
|
+
result: row.responseJson ? JSON.parse(row.responseJson) : null,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
//# sourceMappingURL=cindertrace-response.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { authMiddleware, getUser } from '../middleware/auth.js';
|
|
3
|
+
import { AGENT_VERSION, CONTROL_API_VERSION } from '../config/version.js';
|
|
4
|
+
import { featureFlags } from '../config/features.js';
|
|
5
|
+
import { securityConsoleUrl } from '../services/security-export.js';
|
|
6
|
+
export const consoleRouter = new Hono();
|
|
7
|
+
consoleRouter.use('*', authMiddleware);
|
|
8
|
+
/**
|
|
9
|
+
* GET /console/session
|
|
10
|
+
* What a browser console may show this caller. A static page has no server of its
|
|
11
|
+
* own to read the environment, so this answers what the dashboard derives from
|
|
12
|
+
* server-side checks and build-time public values.
|
|
13
|
+
*/
|
|
14
|
+
consoleRouter.get('/session', (c) => {
|
|
15
|
+
const user = getUser(c);
|
|
16
|
+
c.header('Cache-Control', 'no-store');
|
|
17
|
+
return c.json({
|
|
18
|
+
success: true,
|
|
19
|
+
data: {
|
|
20
|
+
userId: user.userId,
|
|
21
|
+
role: user.role,
|
|
22
|
+
capabilities: {
|
|
23
|
+
hermes: featureFlags().hermes,
|
|
24
|
+
// Whether to offer the terminal. The gateway still verifies everything, including step-up.
|
|
25
|
+
terminal: featureFlags().fleetGateway && user.terminal === true,
|
|
26
|
+
agentVersion: AGENT_VERSION,
|
|
27
|
+
magnusVersion: CONTROL_API_VERSION,
|
|
28
|
+
securityConsoleUrl: securityConsoleUrl() ?? null,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=console.js.map
|
package/dist/routes/cron.js
CHANGED
|
@@ -2,6 +2,7 @@ import { managedCronRouter } from './managed-cron.js';
|
|
|
2
2
|
import { canStageLegacyCronForEnrollment, cronMutationMode, finalizeManagedCronRevocation, installManagedCron, registerManagedCron, reconcileForeignSchedulerSource, revokeManagedCron, stageLegacyCronForEnrollment, updateAndRegisterManagedCron } from '../services/managed-cron.js';
|
|
3
3
|
import { managedCronRegistrationSchema, schedulerSourceSettlementSchema } from '../schemas/managed-cron.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
+
import { cronJobForRole, mergeEnvironmentEdit, nodeForRole } from '../services/secret-views.js';
|
|
5
6
|
import { getDb } from '../db/index.js';
|
|
6
7
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
7
8
|
import { logAuditEvent, sanitizeDetails } from '../services/audit-service.js';
|
|
@@ -68,7 +69,8 @@ cronRouter.get('/', async (c) => {
|
|
|
68
69
|
orderBy: { name: 'asc' },
|
|
69
70
|
include: { node: { select: { name: true } } },
|
|
70
71
|
});
|
|
71
|
-
|
|
72
|
+
const viewer = getUser(c).role;
|
|
73
|
+
return c.json({ success: true, data: jobs.map((job) => cronJobForRole(job, viewer)) });
|
|
72
74
|
});
|
|
73
75
|
// ── Get single cron job ──
|
|
74
76
|
cronRouter.get('/:cronId', async (c) => {
|
|
@@ -79,7 +81,7 @@ cronRouter.get('/:cronId', async (c) => {
|
|
|
79
81
|
});
|
|
80
82
|
if (!job)
|
|
81
83
|
throw new HttpError(404, 'NOT_FOUND', 'Cron job not found');
|
|
82
|
-
return c.json({ success: true, data: job });
|
|
84
|
+
return c.json({ success: true, data: cronJobForRole(job, getUser(c).role) });
|
|
83
85
|
});
|
|
84
86
|
// ── Create cron job ──
|
|
85
87
|
cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -136,7 +138,7 @@ cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
136
138
|
timeoutSeconds: body.timeoutSeconds ?? 600,
|
|
137
139
|
});
|
|
138
140
|
}
|
|
139
|
-
return c.json({ success: true, data: job }, 201);
|
|
141
|
+
return c.json({ success: true, data: cronJobForRole(job, user.role) }, 201);
|
|
140
142
|
});
|
|
141
143
|
cronRouter.post('/scheduler-sources/reconcile', requireRole('ADMIN'), async (c) => {
|
|
142
144
|
const assertion = await parseBody(c, schedulerSourceSettlementSchema);
|
|
@@ -209,7 +211,7 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
209
211
|
...(body.command && { command: body.command }),
|
|
210
212
|
...(body.args && { args: JSON.stringify(body.args) }),
|
|
211
213
|
...(body.workingDirectory !== undefined && { workingDirectory: body.workingDirectory }),
|
|
212
|
-
...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(body.environment)) }),
|
|
214
|
+
...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(user.role === 'ADMIN' ? body.environment : mergeEnvironmentEdit(initial.environment, body.environment))) }),
|
|
213
215
|
...(nextStatus && { status: nextStatus }),
|
|
214
216
|
...(body.timeoutSeconds && { timeoutSeconds: body.timeoutSeconds }),
|
|
215
217
|
};
|
|
@@ -245,7 +247,8 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
245
247
|
timeoutSeconds: job.timeoutSeconds,
|
|
246
248
|
});
|
|
247
249
|
})();
|
|
248
|
-
|
|
250
|
+
// The agent connection above needs the node; its token must never leave the server.
|
|
251
|
+
return c.json({ success: true, data: { ...cronJobForRole(job, user.role), node: nodeForRole(job.node, user.role) } });
|
|
249
252
|
});
|
|
250
253
|
// ── Delete cron job ──
|
|
251
254
|
cronRouter.delete('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { authMiddleware } from '../middleware/auth.js';
|
|
3
|
+
import { watchOathweaveStream } from '../middleware/oathweave.js';
|
|
3
4
|
import { eventBus } from '../services/event-bus.js';
|
|
4
5
|
export const eventsStreamRouter = new Hono();
|
|
5
6
|
eventsStreamRouter.use('*', authMiddleware);
|
|
@@ -19,10 +20,12 @@ eventsStreamRouter.get('/', async (c) => {
|
|
|
19
20
|
let keepAlive;
|
|
20
21
|
let unsubscribe;
|
|
21
22
|
let closed = false;
|
|
23
|
+
let stopWatching;
|
|
22
24
|
// Idempotent cleanup for every exit path: client abort, stream cancel, or a
|
|
23
25
|
// disconnect that happened while the subscription was still being set up.
|
|
24
26
|
const cleanup = () => {
|
|
25
27
|
closed = true;
|
|
28
|
+
stopWatching?.();
|
|
26
29
|
if (keepAlive)
|
|
27
30
|
clearInterval(keepAlive);
|
|
28
31
|
keepAlive = undefined;
|
|
@@ -42,6 +45,13 @@ eventsStreamRouter.get('/', async (c) => {
|
|
|
42
45
|
// Stream closed
|
|
43
46
|
}
|
|
44
47
|
};
|
|
48
|
+
stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
|
|
49
|
+
cleanup();
|
|
50
|
+
try {
|
|
51
|
+
controller.close();
|
|
52
|
+
}
|
|
53
|
+
catch { /* already closed */ }
|
|
54
|
+
});
|
|
45
55
|
// Send initial connection event
|
|
46
56
|
send('connected', { timestamp: new Date().toISOString() });
|
|
47
57
|
// Keep-alive every 30s
|
package/dist/routes/files.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
1
2
|
import { Hono } from 'hono';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
@@ -133,7 +134,7 @@ filesRouter.post('/update', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
133
134
|
return { ...task, status: 'completed' };
|
|
134
135
|
}
|
|
135
136
|
catch (err) {
|
|
136
|
-
const message = err
|
|
137
|
+
const message = clientErrorMessage(err, 'File update failed');
|
|
137
138
|
await db.filePatchTask.update({
|
|
138
139
|
where: { id: task.id },
|
|
139
140
|
data: { status: 'failed', error: message },
|