@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.
Files changed (89) hide show
  1. package/.env.example +37 -0
  2. package/README.md +36 -0
  3. package/agent-version.json +1 -1
  4. package/dist/ai/TOOLS.md +1 -1
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/env-catalog.js +16 -0
  7. package/dist/cli/migrations.js +22 -0
  8. package/dist/cli/verify-initialization.js +12 -0
  9. package/dist/cli.js +28 -0
  10. package/dist/config/console-path.js +17 -0
  11. package/dist/config/env.js +32 -5
  12. package/dist/config/features.js +4 -1
  13. package/dist/config/oathweave.js +42 -0
  14. package/dist/config/rehearsal.js +22 -0
  15. package/dist/db/generated/prisma/internal/class.js +4 -4
  16. package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
  18. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  19. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  20. package/dist/db/read-retry.js +5 -1
  21. package/dist/db/seed.js +8 -0
  22. package/dist/index.js +28 -37
  23. package/dist/middleware/auth.js +21 -0
  24. package/dist/middleware/error-handler.js +10 -10
  25. package/dist/middleware/oathweave-verifier.js +23 -0
  26. package/dist/middleware/oathweave.js +115 -0
  27. package/dist/middleware/rate-limit.js +12 -3
  28. package/dist/openapi/spec.js +45 -4
  29. package/dist/queue/names.js +9 -0
  30. package/dist/queue/workers.js +9 -68
  31. package/dist/routes/assistant.js +30 -12
  32. package/dist/routes/cindertrace-response.js +183 -0
  33. package/dist/routes/console.js +33 -0
  34. package/dist/routes/cron.js +8 -5
  35. package/dist/routes/events-stream.js +10 -0
  36. package/dist/routes/files.js +2 -1
  37. package/dist/routes/fleet-gateway.js +69 -0
  38. package/dist/routes/heartbeat.js +1 -6
  39. package/dist/routes/hermes.js +26 -6
  40. package/dist/routes/hiveskills.js +116 -0
  41. package/dist/routes/library.js +3 -2
  42. package/dist/routes/logs.js +5 -0
  43. package/dist/routes/metrics.js +4 -3
  44. package/dist/routes/nodes.js +45 -8
  45. package/dist/routes/ops.js +24 -6
  46. package/dist/routes/profiling.js +2 -1
  47. package/dist/routes/roles.js +17 -9
  48. package/dist/routes/security-fusion.js +15 -600
  49. package/dist/routes/stats.js +8 -5
  50. package/dist/routes/terminal-ticket.js +3 -3
  51. package/dist/routes/terminal.js +2 -2
  52. package/dist/routes/webhooks.js +15 -15
  53. package/dist/sdk/node-api-client.js +23 -1
  54. package/dist/security/cindertrace-response.js +14 -0
  55. package/dist/security/native-completion-registry.js +14 -0
  56. package/dist/server.js +11 -14
  57. package/dist/services/ai-assistant-service.js +32 -6
  58. package/dist/services/ai-tools.js +15 -8
  59. package/dist/services/ardent-scope-enrollment.js +4 -2
  60. package/dist/services/command-resource-leases.js +2 -4
  61. package/dist/services/forecast-service.js +23 -8
  62. package/dist/services/ops-service.js +20 -25
  63. package/dist/services/prediction-service.js +2 -1
  64. package/dist/services/resource-enrollment.js +57 -4
  65. package/dist/services/secret-views.js +72 -0
  66. package/dist/services/security-export.js +4 -6
  67. package/dist/services/topology-service.js +2 -1
  68. package/dist/services/webhook-security.js +10 -1
  69. package/dist/services/webhook-service.js +14 -6
  70. package/dist/utils/node-url.js +2 -1
  71. package/dist/utils/safe-errors.js +130 -0
  72. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  73. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  74. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  75. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  76. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  77. package/package.json +7 -3
  78. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  79. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  80. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  81. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  82. package/prisma/schema.prisma +22 -0
  83. package/dist/services/security-fusion-actions.js +0 -22
  84. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  85. package/dist/services/security-fusion-ingest.js +0 -101
  86. package/dist/services/security-fusion-outbox.js +0 -185
  87. package/dist/services/security-fusion-receipt-chain.js +0 -134
  88. package/dist/services/security-fusion-service.js +0 -751
  89. package/dist/services/security-fusion-verification.js +0 -50
@@ -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 = `${REDIS_PREFIX}commands`;
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 {
@@ -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 { chat, chatStream, listConversations, getConversationMessages, getUnreadAiMessageCount, markAiMessagesRead, markConversationRead, archiveConversation, deleteConversation, getHeartbeatPrompt, updateHeartbeatPrompt, resetHeartbeatPrompt, listOllamaModels, getModelContextLength, getAiSettings, updateAiSettings, getModelProfiles, updateModelProfiles, approveToolCall, rejectToolCall, createConversationShare, forkConversation, getSharedConversation, } from '../services/ai-assistant-service.js';
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 instanceof Error ? err.message : 'Stream failed' }),
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
@@ -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
- return c.json({ success: true, data: jobs });
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
- return c.json({ success: true, data: job });
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
@@ -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 instanceof Error ? err.message : String(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 },