mstro-app 0.3.0 → 0.3.4

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 (121) hide show
  1. package/README.md +3 -19
  2. package/bin/mstro.js +62 -174
  3. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker.js +4 -3
  5. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -2
  7. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts +6 -1
  9. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  10. package/dist/server/cli/headless/runner.js +36 -4
  11. package/dist/server/cli/headless/runner.js.map +1 -1
  12. package/dist/server/cli/headless/types.d.ts +1 -1
  13. package/dist/server/cli/headless/types.d.ts.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
  15. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.js +3 -2
  17. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  18. package/dist/server/index.js +6 -1
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  21. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  22. package/dist/server/mcp/bouncer-integration.js +85 -114
  23. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  24. package/dist/server/mcp/security-audit.d.ts +3 -3
  25. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  26. package/dist/server/mcp/security-audit.js.map +1 -1
  27. package/dist/server/mcp/server.js +3 -2
  28. package/dist/server/mcp/server.js.map +1 -1
  29. package/dist/server/services/analytics.d.ts +2 -2
  30. package/dist/server/services/analytics.d.ts.map +1 -1
  31. package/dist/server/services/analytics.js.map +1 -1
  32. package/dist/server/services/files.js +7 -7
  33. package/dist/server/services/files.js.map +1 -1
  34. package/dist/server/services/pathUtils.js +1 -1
  35. package/dist/server/services/pathUtils.js.map +1 -1
  36. package/dist/server/services/platform.d.ts +2 -2
  37. package/dist/server/services/platform.d.ts.map +1 -1
  38. package/dist/server/services/platform.js.map +1 -1
  39. package/dist/server/services/sentry.d.ts +1 -1
  40. package/dist/server/services/sentry.d.ts.map +1 -1
  41. package/dist/server/services/sentry.js.map +1 -1
  42. package/dist/server/services/terminal/pty-manager.d.ts +10 -0
  43. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  44. package/dist/server/services/terminal/pty-manager.js +32 -4
  45. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  46. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  47. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  48. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  49. package/dist/server/services/websocket/file-utils.js +48 -23
  50. package/dist/server/services/websocket/file-utils.js.map +1 -1
  51. package/dist/server/services/websocket/git-handlers.js +17 -17
  52. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  53. package/dist/server/services/websocket/git-pr-handlers.js +3 -3
  54. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  55. package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
  56. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  57. package/dist/server/services/websocket/handler.js +1 -1
  58. package/dist/server/services/websocket/handler.js.map +1 -1
  59. package/dist/server/services/websocket/session-handlers.d.ts +1 -1
  60. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  61. package/dist/server/services/websocket/session-handlers.js +12 -11
  62. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  63. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  64. package/dist/server/services/websocket/terminal-handlers.js +1 -1
  65. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  66. package/dist/server/services/websocket/types.d.ts.map +1 -1
  67. package/dist/server/utils/agent-manager.d.ts +22 -2
  68. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  69. package/dist/server/utils/agent-manager.js +2 -2
  70. package/dist/server/utils/agent-manager.js.map +1 -1
  71. package/dist/server/utils/paths.d.ts +0 -12
  72. package/dist/server/utils/paths.d.ts.map +1 -1
  73. package/dist/server/utils/paths.js +0 -12
  74. package/dist/server/utils/paths.js.map +1 -1
  75. package/dist/server/utils/port-manager.js.map +1 -1
  76. package/package.json +4 -3
  77. package/server/README.md +0 -1
  78. package/server/cli/headless/claude-invoker.ts +21 -16
  79. package/server/cli/headless/mcp-config.ts +8 -8
  80. package/server/cli/headless/runner.ts +32 -4
  81. package/server/cli/headless/types.ts +1 -1
  82. package/server/cli/improvisation-session-manager.ts +8 -7
  83. package/server/index.ts +15 -9
  84. package/server/mcp/README.md +0 -5
  85. package/server/mcp/bouncer-integration.ts +116 -188
  86. package/server/mcp/security-audit.ts +4 -4
  87. package/server/mcp/server.ts +6 -5
  88. package/server/services/analytics.ts +3 -3
  89. package/server/services/files.ts +13 -13
  90. package/server/services/pathUtils.ts +2 -2
  91. package/server/services/platform.ts +5 -5
  92. package/server/services/sentry.ts +1 -1
  93. package/server/services/terminal/pty-manager.ts +36 -9
  94. package/server/services/websocket/file-explorer-handlers.ts +1 -1
  95. package/server/services/websocket/file-utils.ts +52 -28
  96. package/server/services/websocket/git-handlers.ts +34 -34
  97. package/server/services/websocket/git-pr-handlers.ts +6 -6
  98. package/server/services/websocket/git-worktree-handlers.ts +20 -20
  99. package/server/services/websocket/handler.ts +2 -2
  100. package/server/services/websocket/session-handlers.ts +31 -30
  101. package/server/services/websocket/tab-handlers.ts +1 -1
  102. package/server/services/websocket/terminal-handlers.ts +2 -2
  103. package/server/services/websocket/types.ts +2 -0
  104. package/server/utils/agent-manager.ts +6 -6
  105. package/server/utils/paths.ts +0 -14
  106. package/server/utils/port-manager.ts +1 -1
  107. package/bin/configure-claude.js +0 -298
  108. package/dist/server/mcp/bouncer-cli.d.ts +0 -3
  109. package/dist/server/mcp/bouncer-cli.d.ts.map +0 -1
  110. package/dist/server/mcp/bouncer-cli.js +0 -99
  111. package/dist/server/mcp/bouncer-cli.js.map +0 -1
  112. package/hooks/bouncer.sh +0 -145
  113. package/server/cli/headless/output-utils.test.ts +0 -225
  114. package/server/cli/headless/stall-assessor.test.ts +0 -165
  115. package/server/cli/headless/tool-watchdog.test.ts +0 -429
  116. package/server/mcp/bouncer-cli.ts +0 -127
  117. package/server/mcp/bouncer-integration.test.ts +0 -161
  118. package/server/mcp/security-patterns.test.ts +0 -258
  119. package/server/services/platform.test.ts +0 -1304
  120. package/server/services/websocket/autocomplete.test.ts +0 -194
  121. package/server/services/websocket/handler.test.ts +0 -20
@@ -10,8 +10,8 @@ import type { SessionRegistry } from './session-registry.js';
10
10
  import type { WebSocketMessage, WSContext } from './types.js';
11
11
 
12
12
  /** Convert tool history entries into OutputLine-compatible lines */
13
- function convertToolHistoryToLines(tools: any[], ts: number): any[] {
14
- const lines: any[] = [];
13
+ function convertToolHistoryToLines(tools: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>, ts: number): Array<Record<string, unknown>> {
14
+ const lines: Array<Record<string, unknown>> = [];
15
15
  for (const tool of tools) {
16
16
  lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
17
17
  if (tool.result !== undefined) {
@@ -22,8 +22,8 @@ function convertToolHistoryToLines(tools: any[], ts: number): any[] {
22
22
  }
23
23
 
24
24
  /** Convert a single movement record into OutputLine-compatible entries */
25
- function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): any[] {
26
- const lines: any[] = [];
25
+ function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: Array<{ toolName: string; toolInput?: Record<string, unknown>; result?: string; isError?: boolean }>; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): Array<Record<string, unknown>> {
26
+ const lines: Array<Record<string, unknown>> = [];
27
27
  const ts = new Date(movement.timestamp).getTime();
28
28
 
29
29
  lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
@@ -67,7 +67,7 @@ function getSession(ctx: HandlerContext, ws: WSContext, tabId: string): Improvis
67
67
  return ctx.sessions.get(sessionId) || null;
68
68
  }
69
69
 
70
- export function buildOutputHistory(session: ImprovisationSessionManager): any[] {
70
+ export function buildOutputHistory(session: ImprovisationSessionManager): Array<Record<string, unknown>> {
71
71
  const history = session.getHistory();
72
72
  return history.movements.flatMap(convertMovementToLines);
73
73
  }
@@ -88,7 +88,7 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
88
88
  ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
89
89
  });
90
90
 
91
- session.on('onMovementComplete', (movement: any) => {
91
+ session.on('onMovementComplete', (movement: Record<string, unknown>) => {
92
92
  ctx.send(ws, { type: 'movementComplete', tabId, data: movement });
93
93
 
94
94
  const registry = ctx.getRegistry('');
@@ -99,7 +99,7 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
99
99
 
100
100
  if (ctx.usageReporter && movement.tokensUsed) {
101
101
  ctx.usageReporter({
102
- tokensUsed: movement.tokensUsed,
102
+ tokensUsed: movement.tokensUsed as number,
103
103
  sessionId: session.getSessionInfo().sessionId,
104
104
  movementId: `${movement.sequenceNumber}`
105
105
  });
@@ -111,15 +111,15 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
111
111
  ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
112
112
  });
113
113
 
114
- session.on('onSessionUpdate', (history: any) => {
114
+ session.on('onSessionUpdate', (history: Record<string, unknown>) => {
115
115
  ctx.send(ws, { type: 'sessionUpdate', tabId, data: history });
116
116
  });
117
117
 
118
- session.on('onPlanNeedsConfirmation', (plan: any) => {
118
+ session.on('onPlanNeedsConfirmation', (plan: Record<string, unknown>) => {
119
119
  ctx.send(ws, { type: 'approvalRequired', tabId, data: plan });
120
120
  });
121
121
 
122
- session.on('onToolUse', (event: any) => {
122
+ session.on('onToolUse', (event: Record<string, unknown>) => {
123
123
  ctx.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
124
124
  });
125
125
 
@@ -163,13 +163,13 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
163
163
  }
164
164
  case 'approve': {
165
165
  const session = requireSession(ctx, ws, tabId);
166
- (session as any).respondToApproval?.(true);
166
+ session.respondToApproval(true);
167
167
  ctx.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
168
168
  break;
169
169
  }
170
170
  case 'reject': {
171
171
  const session = requireSession(ctx, ws, tabId);
172
- (session as any).respondToApproval?.(false);
172
+ session.respondToApproval(false);
173
173
  ctx.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
174
174
  break;
175
175
  }
@@ -309,8 +309,8 @@ export async function resumeHistoricalSession(
309
309
 
310
310
  try {
311
311
  session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
312
- } catch (error: any) {
313
- console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
312
+ } catch (error: unknown) {
313
+ console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error instanceof Error ? error.message : String(error)}. Creating new session.`);
314
314
  session = new ImprovisationSessionManager({ workingDir, model: getModel() });
315
315
  isNewSession = true;
316
316
  }
@@ -381,7 +381,7 @@ function getSessionsCount(workingDir: string): number {
381
381
  return readdirSync(sessionsDir).filter((name: string) => name.endsWith('.json')).length;
382
382
  }
383
383
 
384
- function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
384
+ function getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown> | null>; total: number; hasMore: boolean } {
385
385
  const sessionsDir = join(workingDir, '.mstro', 'history');
386
386
 
387
387
  if (!existsSync(sessionsDir)) {
@@ -405,8 +405,8 @@ function getSessionsList(workingDir: string, limit: number = 20, offset: number
405
405
  const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
406
406
  const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
407
407
 
408
- const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
409
- userPrompt: m.userPrompt?.slice(0, 100) || ''
408
+ const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
409
+ userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
410
410
  }));
411
411
 
412
412
  return {
@@ -426,7 +426,7 @@ function getSessionsList(workingDir: string, limit: number = 20, offset: number
426
426
  return { sessions, total, hasMore: offset + limit < total };
427
427
  }
428
428
 
429
- function getSessionById(workingDir: string, sessionId: string): any {
429
+ function getSessionById(workingDir: string, sessionId: string): Record<string, unknown> | null {
430
430
  const sessionsDir = join(workingDir, '.mstro', 'history');
431
431
  if (!existsSync(sessionsDir)) return null;
432
432
 
@@ -508,32 +508,33 @@ function clearAllSessions(workingDir: string): { success: boolean; deletedCount:
508
508
  }
509
509
  }
510
510
 
511
- function movementMatchesQuery(movements: any[] | undefined, lowerQuery: string): boolean {
511
+ function movementMatchesQuery(movements: Array<Record<string, unknown>> | undefined, lowerQuery: string): boolean {
512
512
  if (!movements) return false;
513
- return movements.some((m: any) =>
514
- m.userPrompt?.toLowerCase().includes(lowerQuery) ||
515
- m.summary?.toLowerCase().includes(lowerQuery) ||
516
- m.assistantResponse?.toLowerCase().includes(lowerQuery)
513
+ return movements.some((m: Record<string, unknown>) =>
514
+ (typeof m.userPrompt === 'string' && m.userPrompt.toLowerCase().includes(lowerQuery)) ||
515
+ (typeof m.summary === 'string' && m.summary.toLowerCase().includes(lowerQuery)) ||
516
+ (typeof m.assistantResponse === 'string' && m.assistantResponse.toLowerCase().includes(lowerQuery))
517
517
  );
518
518
  }
519
519
 
520
- function buildSessionSummary(historyData: any): any {
521
- const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
522
- const movementPreviews = (historyData.movements || []).slice(0, 3).map((m: any) => ({
523
- userPrompt: m.userPrompt?.slice(0, 100) || ''
520
+ function buildSessionSummary(historyData: Record<string, unknown>): Record<string, unknown> {
521
+ const movements = historyData.movements as Array<Record<string, unknown>> | undefined;
522
+ const firstPrompt = (typeof movements?.[0]?.userPrompt === 'string' ? movements[0].userPrompt : '') || '';
523
+ const movementPreviews = (movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
524
+ userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
524
525
  }));
525
526
  return {
526
527
  sessionId: historyData.sessionId,
527
528
  startedAt: historyData.startedAt,
528
529
  lastActivityAt: historyData.lastActivityAt,
529
530
  totalTokens: historyData.totalTokens,
530
- movementCount: historyData.movements?.length || 0,
531
+ movementCount: movements?.length || 0,
531
532
  title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
532
533
  movements: movementPreviews
533
534
  };
534
535
  }
535
536
 
536
- function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
537
+ function searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: Array<Record<string, unknown>>; total: number; hasMore: boolean } {
537
538
  const sessionsDir = join(workingDir, '.mstro', 'history');
538
539
  if (!existsSync(sessionsDir)) return { sessions: [], total: 0, hasMore: false };
539
540
 
@@ -548,7 +549,7 @@ function searchSessions(workingDir: string, query: string, limit: number = 20, o
548
549
  return timestampB - timestampA;
549
550
  });
550
551
 
551
- const allMatches: any[] = [];
552
+ const allMatches: Array<Record<string, unknown>> = [];
552
553
  for (const filename of historyFiles) {
553
554
  try {
554
555
  const content = readFileSync(join(sessionsDir, filename), 'utf-8');
@@ -11,7 +11,7 @@ export function handleGetActiveTabs(ctx: HandlerContext, ws: WSContext, workingD
11
11
  const registry = ctx.getRegistry(workingDir);
12
12
  const allTabs = registry.getAllTabs();
13
13
 
14
- const tabs: Record<string, any> = {};
14
+ const tabs: Record<string, unknown> = {};
15
15
  for (const [tabId, regTab] of Object.entries(allTabs)) {
16
16
  const session = ctx.sessions.get(regTab.sessionId);
17
17
  if (session) {
@@ -83,12 +83,12 @@ function handleTerminalInit(
83
83
  shell,
84
84
  is_reconnect: isReconnect,
85
85
  });
86
- } catch (error: any) {
86
+ } catch (error: unknown) {
87
87
  console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
88
88
  ctx.send(ws, {
89
89
  type: 'terminalError',
90
90
  terminalId,
91
- data: { error: error.message || 'Failed to create terminal' }
91
+ data: { error: (error instanceof Error ? error.message : String(error)) || 'Failed to create terminal' }
92
92
  });
93
93
  removeTerminalSubscriber(ctx, terminalId, ws);
94
94
  }
@@ -108,6 +108,7 @@ export interface WebSocketMessage {
108
108
  | 'updateSettings';
109
109
  tabId?: string;
110
110
  terminalId?: string;
111
+ // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
111
112
  data?: any;
112
113
  /** Injected by server relay for sandboxed shared users (control + view) */
113
114
  _permission?: 'control' | 'view';
@@ -211,6 +212,7 @@ export interface WebSocketResponse {
211
212
  | 'settingsUpdated';
212
213
  tabId?: string;
213
214
  terminalId?: string;
215
+ // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
214
216
  data?: any;
215
217
  }
216
218
 
@@ -289,14 +289,14 @@ export class AgentManager {
289
289
  /**
290
290
  * Extract agent names from a score object
291
291
  */
292
- extractAgentNamesFromScore(score: any): string[] {
292
+ extractAgentNamesFromScore(score: { movements?: Array<{ musicians?: Array<{ type?: string; config?: { agent?: string }; role?: string }> }> }): string[] {
293
293
  if (!Array.isArray(score.movements)) return [];
294
294
 
295
- const names = (score.movements as any[])
295
+ const names = score.movements
296
296
  .flatMap(m => Array.isArray(m.musicians) ? m.musicians : [])
297
- .filter((m: any) => m.type === 'custom')
298
- .map((m: any) => m.config?.agent || m.role)
299
- .filter(Boolean);
297
+ .filter(m => m.type === 'custom')
298
+ .map(m => m.config?.agent || m.role)
299
+ .filter(Boolean) as string[];
300
300
 
301
301
  return [...new Set<string>(names)];
302
302
  }
@@ -304,7 +304,7 @@ export class AgentManager {
304
304
  /**
305
305
  * Ensure all agents required by a score are available
306
306
  */
307
- async ensureScoreAgentsAvailable(score: any, workingDir: string): Promise<Map<string, AgentInfo>> {
307
+ async ensureScoreAgentsAvailable(score: { movements?: Array<{ musicians?: Array<{ type?: string; config?: { agent?: string }; role?: string }> }> }, workingDir: string): Promise<Map<string, AgentInfo>> {
308
308
  const agentNames = this.extractAgentNamesFromScore(score);
309
309
  const results = new Map<string, AgentInfo>();
310
310
 
@@ -29,17 +29,3 @@ export const MSTRO_ROOT = resolve(__dirname, '../..');
29
29
  */
30
30
  export const MCP_SERVER_PATH = resolve(MSTRO_ROOT, 'server/mcp/server.ts');
31
31
 
32
- /**
33
- * Path to the MCP bouncer configuration template
34
- */
35
- export const MCP_CONFIG_TEMPLATE_PATH = resolve(MSTRO_ROOT, 'mstro-bouncer-mcp.json');
36
-
37
- /**
38
- * Path to the hooks directory
39
- */
40
- export const HOOKS_DIR = resolve(MSTRO_ROOT, 'hooks');
41
-
42
- /**
43
- * Path to the bouncer hook script
44
- */
45
- export const BOUNCER_HOOK = resolve(HOOKS_DIR, 'bouncer.sh');
@@ -20,7 +20,7 @@ async function isPortAvailable(port: number): Promise<boolean> {
20
20
  return new Promise((resolve) => {
21
21
  const server = createServer()
22
22
 
23
- server.once('error', (err: any) => {
23
+ server.once('error', (err: NodeJS.ErrnoException) => {
24
24
  if (err.code === 'EADDRINUSE') {
25
25
  resolve(false)
26
26
  } else {
@@ -1,298 +0,0 @@
1
- #!/usr/bin/env node
2
- // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
3
- // Licensed under the MIT License. See LICENSE file for details.
4
-
5
- /**
6
- * Mstro Claude Configuration Tool
7
- *
8
- * Automatically configures ~/.claude/settings.json and installs
9
- * the bouncer hook for Claude Code integration.
10
- *
11
- * Usage:
12
- * npx mstro configure-hooks
13
- * node bin/configure-claude.js
14
- */
15
-
16
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
- import { homedir } from 'node:os';
18
- import { dirname, join, resolve } from 'node:path';
19
- import { createInterface } from 'node:readline';
20
- import { fileURLToPath } from 'node:url';
21
-
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = dirname(__filename);
24
- const MSTRO_ROOT = resolve(__dirname, '..');
25
-
26
- const CLAUDE_DIR = join(homedir(), '.claude');
27
- const CLAUDE_HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
28
- const CLAUDE_SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
29
- const _BOUNCER_HOOK_SOURCE = join(MSTRO_ROOT, 'hooks', 'bouncer.sh');
30
- const BOUNCER_CLI_PATH = join(MSTRO_ROOT, 'server', 'mcp', 'bouncer-cli.ts');
31
- const TSX_PATH = join(MSTRO_ROOT, 'node_modules', '.bin', 'tsx');
32
-
33
- // ANSI colors
34
- const colors = {
35
- reset: '\x1b[0m',
36
- bold: '\x1b[1m',
37
- green: '\x1b[32m',
38
- yellow: '\x1b[33m',
39
- blue: '\x1b[34m',
40
- red: '\x1b[31m',
41
- dim: '\x1b[2m',
42
- };
43
-
44
- function log(msg, color = '') {
45
- console.log(`${color}${msg}${colors.reset}`);
46
- }
47
-
48
- function prompt(question) {
49
- const rl = createInterface({ input: process.stdin, output: process.stdout });
50
- return new Promise((resolve) => {
51
- rl.question(question, (answer) => {
52
- rl.close();
53
- resolve(answer.trim().toLowerCase());
54
- });
55
- });
56
- }
57
-
58
- /**
59
- * Generate the bouncer hook script with embedded paths
60
- * This ensures the hook knows where to find the bouncer CLI regardless of
61
- * how/where mstro was installed (npm global, npx, etc.)
62
- */
63
- function generateBouncerHook(tsxPath, bouncerCliPath) {
64
- return `#!/usr/bin/env bash
65
- #
66
- # Mstro Bouncer Gate - Claude Code Hook
67
- #
68
- # This hook intercepts Claude Code tool calls and routes them through
69
- # the Mstro bouncer for security analysis before execution.
70
- #
71
- # Generated by: npx mstro configure-hooks
72
- # Dependencies: Node.js (no jq or bun required)
73
- #
74
-
75
- set -euo pipefail
76
-
77
- # Paths configured at install time
78
- TSX_PATH="${tsxPath}"
79
- BOUNCER_CLI="${bouncerCliPath}"
80
-
81
- # User-configurable settings
82
- BOUNCER_TIMEOUT="\${BOUNCER_TIMEOUT:-10}"
83
- BOUNCER_LOG="\${BOUNCER_LOG:-$HOME/.claude/logs/bouncer.log}"
84
-
85
- # Ensure log directory exists
86
- mkdir -p "$(dirname "$BOUNCER_LOG")"
87
-
88
- # Read hook input from stdin
89
- INPUT=$(cat)
90
-
91
- # Run the bouncer via tsx (handles TypeScript execution)
92
- if [ -x "$TSX_PATH" ] && [ -f "$BOUNCER_CLI" ]; then
93
- RESULT=$(echo "$INPUT" | timeout "$BOUNCER_TIMEOUT" "$TSX_PATH" "$BOUNCER_CLI" 2>> "$BOUNCER_LOG" || echo '{"decision": "allow", "reason": "Bouncer timeout or error"}')
94
- echo "$RESULT"
95
- else
96
- # Fallback: use inline Node.js for basic pattern matching
97
- node --input-type=module -e "
98
- const input = JSON.parse(process.argv[1]);
99
- const toolName = input.tool_name || input.toolName || 'unknown';
100
- const toolInput = input.input || input.toolInput || {};
101
-
102
- // Quick allow for read-only operations
103
- const readOnly = ['Read', 'Glob', 'Grep', 'Search', 'List', 'WebFetch', 'WebSearch'];
104
- if (readOnly.includes(toolName)) {
105
- console.log(JSON.stringify({ decision: 'allow', reason: 'Read-only operation' }));
106
- process.exit(0);
107
- }
108
-
109
- // Build operation string
110
- let op = toolName + ': ';
111
- if (toolName === 'Bash') op += toolInput.command || '';
112
- else if (['Write', 'Edit'].includes(toolName)) op += toolInput.file_path || toolInput.filePath || '';
113
- else op += JSON.stringify(toolInput);
114
-
115
- // Critical threat patterns
116
- const threats = [
117
- [/rm\\s+-rf\\s+(\\/|~)(\\$|\\s)/, 'recursive delete of root/home'],
118
- [/:\\(\\)\\{.*\\}/, 'fork bomb'],
119
- [/dd\\s+if=\\/dev\\/zero\\s+of=\\/dev\\/sd/, 'disk overwrite'],
120
- [/mkfs\\s+\\/dev\\/sd/, 'filesystem format'],
121
- ];
122
-
123
- for (const [pattern, reason] of threats) {
124
- if (pattern.test(op)) {
125
- console.log(JSON.stringify({ decision: 'deny', reason: 'Critical threat: ' + reason }));
126
- process.exit(0);
127
- }
128
- }
129
-
130
- console.log(JSON.stringify({ decision: 'allow', reason: 'Fallback: basic check passed' }));
131
- " "$INPUT"
132
- fi
133
- `;
134
- }
135
-
136
- function ensureDirectories() {
137
- log('Step 1: Checking ~/.claude directory structure...', colors.bold);
138
-
139
- for (const dir of [CLAUDE_DIR, CLAUDE_HOOKS_DIR, join(CLAUDE_DIR, 'logs')]) {
140
- if (!existsSync(dir)) {
141
- log(` Creating ${dir}`, colors.dim);
142
- mkdirSync(dir, { recursive: true });
143
- } else {
144
- log(` ${dir} exists`, colors.green);
145
- }
146
- }
147
-
148
- log(' Done!\n', colors.green);
149
- }
150
-
151
- async function installBouncerHook(forceYes, isInteractive) {
152
- log('Step 2: Installing bouncer hook...', colors.bold);
153
-
154
- const hookDest = join(CLAUDE_HOOKS_DIR, 'bouncer.sh');
155
- const hookContent = generateBouncerHook(TSX_PATH, BOUNCER_CLI_PATH);
156
- const hookExists = existsSync(hookDest);
157
-
158
- if (hookExists) {
159
- log(` Hook already exists at ${hookDest}`, colors.yellow);
160
- let overwrite = forceYes;
161
-
162
- if (!forceYes && isInteractive) {
163
- const answer = await prompt(' Overwrite existing hook? [y/N]: ');
164
- overwrite = answer === 'y' || answer === 'yes';
165
- }
166
-
167
- if (!overwrite) {
168
- log(' Skipping hook installation', colors.dim);
169
- log(' Done!\n', colors.green);
170
- return hookDest;
171
- }
172
- }
173
-
174
- writeFileSync(hookDest, hookContent);
175
- chmodSync(hookDest, 0o755);
176
- log(` ${hookExists ? 'Overwrote' : 'Installed'} ${hookDest}`, colors.green);
177
- log(' Done!\n', colors.green);
178
- return hookDest;
179
- }
180
-
181
- function loadSettings() {
182
- if (!existsSync(CLAUDE_SETTINGS_FILE)) {
183
- return {};
184
- }
185
- try {
186
- const content = readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8');
187
- const settings = JSON.parse(content);
188
- log(` Loaded existing settings from ${CLAUDE_SETTINGS_FILE}`, colors.dim);
189
- return settings;
190
- } catch (err) {
191
- log(` Warning: Could not parse existing settings.json: ${err.message}`, colors.yellow);
192
- log(' Will create new settings file', colors.dim);
193
- return {};
194
- }
195
- }
196
-
197
- async function configureSettings(hookDest, forceYes, isInteractive) {
198
- log('Step 3: Configuring settings.json...', colors.bold);
199
-
200
- const settings = loadSettings();
201
- if (!settings.hooks) {
202
- settings.hooks = {};
203
- }
204
-
205
- const bouncerHook = { type: 'command', command: hookDest, timeout: 10000 };
206
- const bouncerHookConfig = [
207
- { matcher: 'Bash', hooks: [bouncerHook] },
208
- { matcher: 'Write', hooks: [bouncerHook] },
209
- { matcher: 'Edit', hooks: [bouncerHook] }
210
- ];
211
-
212
- const existingPreToolUse = settings.hooks.PreToolUse;
213
-
214
- if (existingPreToolUse) {
215
- log(' Existing PreToolUse hook configuration found:', colors.yellow);
216
- log(` ${JSON.stringify(existingPreToolUse, null, 2).split('\n').join('\n ')}`, colors.dim);
217
-
218
- let update = forceYes;
219
- if (!forceYes && isInteractive) {
220
- const answer = await prompt(' Update PreToolUse hook to use Mstro bouncer? [y/N]: ');
221
- update = answer === 'y' || answer === 'yes';
222
- }
223
-
224
- if (!update) {
225
- log(' Keeping existing PreToolUse configuration', colors.dim);
226
- } else {
227
- settings.hooks.PreToolUse = bouncerHookConfig;
228
- log(' Updated PreToolUse hook configuration', colors.green);
229
- }
230
- } else {
231
- settings.hooks.PreToolUse = bouncerHookConfig;
232
- log(' Added PreToolUse hook configuration', colors.green);
233
- }
234
-
235
- const settingsContent = JSON.stringify(settings, null, 2);
236
- const settingsExists = existsSync(CLAUDE_SETTINGS_FILE);
237
-
238
- if (settingsExists) {
239
- const currentContent = readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8');
240
- if (currentContent === settingsContent) {
241
- log(' No changes needed to settings.json', colors.dim);
242
- } else {
243
- const backupPath = `${CLAUDE_SETTINGS_FILE}.backup.${Date.now()}`;
244
- writeFileSync(backupPath, currentContent);
245
- log(` Backed up existing settings to ${backupPath}`, colors.dim);
246
- writeFileSync(CLAUDE_SETTINGS_FILE, settingsContent);
247
- log(` Updated ${CLAUDE_SETTINGS_FILE}`, colors.green);
248
- }
249
- } else {
250
- writeFileSync(CLAUDE_SETTINGS_FILE, settingsContent);
251
- log(` Created ${CLAUDE_SETTINGS_FILE}`, colors.green);
252
- }
253
-
254
- log(' Done!\n', colors.green);
255
- return settingsContent;
256
- }
257
-
258
- function printSummary(hookDest, settingsContent) {
259
- log('=== Configuration Complete ===\n', colors.bold + colors.green);
260
- log('The following files have been configured:', colors.bold);
261
- log(` ${CLAUDE_SETTINGS_FILE}`, colors.dim);
262
- log(` ${hookDest}`, colors.dim);
263
- log('');
264
- log('Your settings.json now contains:', colors.bold);
265
- log(`${settingsContent.split('\n').map(l => ` ${l}`).join('\n')}`, colors.dim);
266
- log('');
267
- log('How the bouncer works:', colors.bold);
268
- log('');
269
- log(' Mstro sessions (headless): Full 2-layer security', colors.green);
270
- log(' Layer 1: Pattern matching (<5ms) - instant allow/deny for known operations', colors.dim);
271
- log(' Layer 2: AI analysis (~200-500ms) - context-aware review of ambiguous operations', colors.dim);
272
- log('');
273
- log(' Claude Code terminal REPL (claude): 1-layer security', colors.yellow);
274
- log(' Layer 1: Pattern matching only - blocks critical threats (fork bombs,', colors.dim);
275
- log(' destructive commands), allows everything else', colors.dim);
276
- log(' The AI analysis layer requires a running mstro server.', colors.dim);
277
- log('');
278
- log('To disable the bouncer hook, remove the PreToolUse entry from', colors.dim);
279
- log(`${CLAUDE_SETTINGS_FILE}`, colors.dim);
280
- log('');
281
- }
282
-
283
- async function main() {
284
- log('\n=== Mstro Claude Configuration ===\n', colors.bold + colors.blue);
285
-
286
- const isInteractive = process.stdin.isTTY;
287
- const forceYes = process.argv.includes('--yes') || process.argv.includes('-y');
288
-
289
- ensureDirectories();
290
- const hookDest = await installBouncerHook(forceYes, isInteractive);
291
- const settingsContent = await configureSettings(hookDest, forceYes, isInteractive);
292
- printSummary(hookDest, settingsContent);
293
- }
294
-
295
- main().catch((err) => {
296
- log(`\nError: ${err.message}`, colors.red);
297
- process.exit(1);
298
- });
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
3
- //# sourceMappingURL=bouncer-cli.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"bouncer-cli.d.ts","sourceRoot":"","sources":["../../../server/mcp/bouncer-cli.ts"],"names":[],"mappings":""}