mstro-app 0.3.0 → 0.3.1

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 (110) hide show
  1. package/bin/mstro.js +65 -2
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +4 -3
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/mcp-config.js +2 -2
  6. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  7. package/dist/server/cli/headless/runner.d.ts +6 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +36 -4
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/types.d.ts +1 -1
  12. package/dist/server/cli/headless/types.d.ts.map +1 -1
  13. package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
  14. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  15. package/dist/server/cli/improvisation-session-manager.js +3 -2
  16. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  17. package/dist/server/index.js +6 -1
  18. package/dist/server/index.js.map +1 -1
  19. package/dist/server/mcp/bouncer-cli.js +53 -14
  20. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  21. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  22. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  23. package/dist/server/mcp/bouncer-integration.js +70 -7
  24. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  25. package/dist/server/mcp/security-audit.d.ts +3 -3
  26. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  27. package/dist/server/mcp/security-audit.js.map +1 -1
  28. package/dist/server/mcp/server.js +3 -2
  29. package/dist/server/mcp/server.js.map +1 -1
  30. package/dist/server/services/analytics.d.ts +2 -2
  31. package/dist/server/services/analytics.d.ts.map +1 -1
  32. package/dist/server/services/analytics.js.map +1 -1
  33. package/dist/server/services/files.js +7 -7
  34. package/dist/server/services/files.js.map +1 -1
  35. package/dist/server/services/pathUtils.js +1 -1
  36. package/dist/server/services/pathUtils.js.map +1 -1
  37. package/dist/server/services/platform.d.ts +2 -2
  38. package/dist/server/services/platform.d.ts.map +1 -1
  39. package/dist/server/services/platform.js.map +1 -1
  40. package/dist/server/services/sentry.d.ts +1 -1
  41. package/dist/server/services/sentry.d.ts.map +1 -1
  42. package/dist/server/services/sentry.js.map +1 -1
  43. package/dist/server/services/terminal/pty-manager.d.ts +10 -0
  44. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  45. package/dist/server/services/terminal/pty-manager.js +32 -4
  46. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  47. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  48. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  49. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  50. package/dist/server/services/websocket/file-utils.js +27 -8
  51. package/dist/server/services/websocket/file-utils.js.map +1 -1
  52. package/dist/server/services/websocket/git-handlers.js +17 -17
  53. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  54. package/dist/server/services/websocket/git-pr-handlers.js +3 -3
  55. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  56. package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
  57. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  58. package/dist/server/services/websocket/handler.js +1 -1
  59. package/dist/server/services/websocket/handler.js.map +1 -1
  60. package/dist/server/services/websocket/session-handlers.d.ts +1 -1
  61. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  62. package/dist/server/services/websocket/session-handlers.js +12 -11
  63. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  64. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  65. package/dist/server/services/websocket/terminal-handlers.js +1 -1
  66. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  67. package/dist/server/services/websocket/types.d.ts.map +1 -1
  68. package/dist/server/utils/agent-manager.d.ts +22 -2
  69. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  70. package/dist/server/utils/agent-manager.js +2 -2
  71. package/dist/server/utils/agent-manager.js.map +1 -1
  72. package/dist/server/utils/port-manager.js.map +1 -1
  73. package/hooks/bouncer.sh +17 -3
  74. package/package.json +4 -2
  75. package/server/cli/headless/claude-invoker.ts +21 -16
  76. package/server/cli/headless/mcp-config.ts +8 -8
  77. package/server/cli/headless/runner.ts +32 -4
  78. package/server/cli/headless/types.ts +1 -1
  79. package/server/cli/improvisation-session-manager.ts +8 -7
  80. package/server/index.ts +15 -9
  81. package/server/mcp/bouncer-cli.ts +73 -20
  82. package/server/mcp/bouncer-integration.ts +99 -16
  83. package/server/mcp/security-audit.ts +4 -4
  84. package/server/mcp/server.ts +6 -5
  85. package/server/services/analytics.ts +3 -3
  86. package/server/services/files.ts +13 -13
  87. package/server/services/pathUtils.ts +2 -2
  88. package/server/services/platform.ts +5 -5
  89. package/server/services/sentry.ts +1 -1
  90. package/server/services/terminal/pty-manager.ts +36 -9
  91. package/server/services/websocket/file-explorer-handlers.ts +1 -1
  92. package/server/services/websocket/file-utils.ts +28 -9
  93. package/server/services/websocket/git-handlers.ts +34 -34
  94. package/server/services/websocket/git-pr-handlers.ts +6 -6
  95. package/server/services/websocket/git-worktree-handlers.ts +20 -20
  96. package/server/services/websocket/handler.ts +2 -2
  97. package/server/services/websocket/session-handlers.ts +31 -30
  98. package/server/services/websocket/tab-handlers.ts +1 -1
  99. package/server/services/websocket/terminal-handlers.ts +2 -2
  100. package/server/services/websocket/types.ts +2 -0
  101. package/server/utils/agent-manager.ts +6 -6
  102. package/server/utils/port-manager.ts +1 -1
  103. package/server/cli/headless/output-utils.test.ts +0 -225
  104. package/server/cli/headless/stall-assessor.test.ts +0 -165
  105. package/server/cli/headless/tool-watchdog.test.ts +0 -429
  106. package/server/mcp/bouncer-integration.test.ts +0 -161
  107. package/server/mcp/security-patterns.test.ts +0 -258
  108. package/server/services/platform.test.ts +0 -1304
  109. package/server/services/websocket/autocomplete.test.ts +0 -194
  110. package/server/services/websocket/handler.test.ts +0 -20
@@ -19,7 +19,7 @@ export interface ToolUseEvent {
19
19
  toolId?: string;
20
20
  index?: number;
21
21
  partialJson?: string;
22
- completeInput?: any;
22
+ completeInput?: Record<string, unknown>;
23
23
  result?: string;
24
24
  isError?: boolean;
25
25
  }
@@ -114,7 +114,7 @@ export class ImprovisationSessionManager extends EventEmitter {
114
114
  private currentRunner: HeadlessRunner | null = null;
115
115
  private options: ImprovisationOptions;
116
116
  private pendingApproval?: {
117
- plan: any;
117
+ plan: unknown;
118
118
  resolve: (approved: boolean) => void;
119
119
  };
120
120
  private outputQueue: Array<{ text: string; timestamp: number }> = [];
@@ -129,7 +129,7 @@ export class ImprovisationSessionManager extends EventEmitter {
129
129
  /** Timestamp when current execution started (for accurate elapsed time across reconnects) */
130
130
  private _executionStartTimestamp: number | undefined;
131
131
  /** Buffered events during current execution, for replay on reconnect */
132
- private executionEventLog: Array<{ type: string; data: any; timestamp: number }> = [];
132
+ private executionEventLog: Array<{ type: string; data: unknown; timestamp: number }> = [];
133
133
  /** Set by cancel() to signal the retry loop to exit */
134
134
  private _cancelled: boolean = false;
135
135
 
@@ -383,19 +383,20 @@ export class ImprovisationSessionManager extends EventEmitter {
383
383
  this.emitMovementComplete(movement, result, _execStart, sequenceNumber);
384
384
  return movement;
385
385
 
386
- } catch (error: any) {
386
+ } catch (error: unknown) {
387
387
  this._isExecuting = false;
388
388
  this._executionStartTimestamp = undefined;
389
389
  this.executionEventLog = [];
390
390
  this.currentRunner = null;
391
391
  this.emit('onMovementError', error);
392
+ const errorMessage = error instanceof Error ? error.message : String(error);
392
393
  trackEvent(AnalyticsEvents.IMPROVISE_MOVEMENT_ERROR, {
393
- error_message: error.message?.slice(0, 200),
394
+ error_message: errorMessage.slice(0, 200),
394
395
  sequence_number: this.history.movements.length + 1,
395
396
  duration_ms: Date.now() - _execStart,
396
397
  model: this.options.model || 'default',
397
398
  });
398
- this.queueOutput(`\n❌ Error: ${error.message}\n`);
399
+ this.queueOutput(`\n❌ Error: ${errorMessage}\n`);
399
400
  this.flushOutputQueue();
400
401
  throw error;
401
402
  } finally {
@@ -1510,7 +1511,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1510
1511
  * Request user approval for a plan
1511
1512
  * Returns a promise that resolves when the user approves/rejects
1512
1513
  */
1513
- async requestApproval(plan: any): Promise<boolean> {
1514
+ async requestApproval(plan: unknown): Promise<boolean> {
1514
1515
  return new Promise((resolve) => {
1515
1516
  this.pendingApproval = { plan, resolve };
1516
1517
  this.emit('onApprovalRequired', plan);
@@ -1559,7 +1560,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1559
1560
  * Get buffered execution events for replay on reconnect.
1560
1561
  * Only meaningful while isExecuting is true.
1561
1562
  */
1562
- getExecutionEventLog(): Array<{ type: string; data: any; timestamp: number }> {
1563
+ getExecutionEventLog(): Array<{ type: string; data: unknown; timestamp: number }> {
1563
1564
  return this.executionEventLog;
1564
1565
  }
1565
1566
 
package/server/index.ts CHANGED
@@ -7,11 +7,11 @@
7
7
 
8
8
  import { randomBytes } from 'node:crypto'
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
- import type { IncomingMessage } from 'node:http'
10
+ import type { IncomingMessage, Server } from 'node:http'
11
11
  import { homedir } from 'node:os'
12
12
  import { basename, join } from 'node:path'
13
13
  import { serve } from '@hono/node-server'
14
- import { Hono } from 'hono'
14
+ import { type Context, Hono, type Next } from 'hono'
15
15
  import { cors } from 'hono/cors'
16
16
  import { logger } from 'hono/logger'
17
17
  import { type WebSocket as NodeWebSocket, WebSocketServer } from 'ws'
@@ -26,10 +26,10 @@ import {
26
26
  import { AnalyticsEvents, initAnalytics, shutdownAnalytics, trackEvent } from './services/analytics.js'
27
27
  import { AuthService } from './services/auth.js'
28
28
  import { FileService } from './services/files.js'
29
- import { InstanceRegistry } from './services/instances.js'
29
+ import { InstanceRegistry, type MstroInstance } from './services/instances.js'
30
30
  import { PlatformConnection } from './services/platform.js'
31
31
  import { captureException, flushSentry, initSentry } from './services/sentry.js'
32
- import { getPTYManager } from './services/terminal/pty-manager.js'
32
+ import { getPTYManager, reloadPty } from './services/terminal/pty-manager.js'
33
33
  import { WebSocketImproviseHandler } from './services/websocket/index.js'
34
34
  import type { WSContext } from './services/websocket/types.js'
35
35
  import { findAvailablePort } from './utils/port.js'
@@ -126,7 +126,7 @@ const fileService = new FileService(WORKING_DIR)
126
126
  const wsHandler = new WebSocketImproviseHandler()
127
127
 
128
128
  // Instance registration deferred to startServer() when port is known
129
- let _currentInstance: any
129
+ let _currentInstance: MstroInstance | undefined
130
130
 
131
131
  // Global middleware
132
132
  // In production, restrict CORS to block cross-origin browser requests to localhost.
@@ -149,7 +149,7 @@ app.use('*', logger())
149
149
  // Authentication Middleware
150
150
  // ========================================
151
151
 
152
- const authMiddleware = async (c: any, next: any) => {
152
+ const authMiddleware = async (c: Context, next: Next) => {
153
153
  // Skip auth for health check and config
154
154
  const publicPaths = ['/health', '/api/config']
155
155
  if (publicPaths.some(path => c.req.path.startsWith(path))) {
@@ -207,6 +207,12 @@ app.route('/api/improvise', createImproviseRoutes(WORKING_DIR))
207
207
  app.route('/api/files', createFileRoutes(fileService))
208
208
  app.route('/api/notifications', createNotificationRoutes(WORKING_DIR))
209
209
 
210
+ // Reload node-pty after setup-terminal compiles the native module
211
+ app.post('/api/reload-pty', async (c) => {
212
+ const success = await reloadPty()
213
+ return c.json({ success, available: success })
214
+ })
215
+
210
216
  // ========================================
211
217
  // Static File Serving (Production Only)
212
218
  // ========================================
@@ -257,7 +263,7 @@ function wrapWebSocket(ws: NodeWebSocket, workingDir: string): WSContext {
257
263
  * This allows messages from the web (via platform) to be handled by the same wsHandler
258
264
  */
259
265
  function createPlatformRelayContext(
260
- platformSend: (message: any) => void,
266
+ platformSend: (message: unknown) => void,
261
267
  workingDir: string
262
268
  ): WSContext {
263
269
  return {
@@ -299,7 +305,7 @@ async function startServer() {
299
305
  })
300
306
 
301
307
  // Create WebSocket server attached to the HTTP server
302
- const wss = new WebSocketServer({ server: server as any })
308
+ const wss = new WebSocketServer({ server: server as Server })
303
309
 
304
310
  wss.on('connection', (ws: NodeWebSocket, req: IncomingMessage) => {
305
311
  const url = new URL(req.url || '/', `http://localhost:${PORT}`)
@@ -354,7 +360,7 @@ async function startServer() {
354
360
 
355
361
  // Queue for messages that arrive before relay context is ready
356
362
  // This handles race conditions where initTab arrives before web_connected
357
- let pendingRelayMessages: any[] = []
363
+ let pendingRelayMessages: unknown[] = []
358
364
 
359
365
  // Connect to platform
360
366
  const platformConnection = new PlatformConnection(WORKING_DIR, {
@@ -18,11 +18,19 @@
18
18
  import { type BouncerReviewRequest, reviewOperation } from './bouncer-integration.js';
19
19
 
20
20
  interface HookInput {
21
+ // Tool identification (mstro: toolName, Claude Code: tool_name)
21
22
  tool_name?: string;
22
23
  toolName?: string;
23
- input?: Record<string, any>;
24
- toolInput?: Record<string, any>;
25
- // Conversation context from Claude Code hooks
24
+ // Tool parameters (mstro: input/toolInput, Claude Code: tool_input)
25
+ input?: Record<string, unknown>;
26
+ toolInput?: Record<string, unknown>;
27
+ tool_input?: Record<string, unknown>;
28
+ // Claude Code hook metadata
29
+ hook_event_name?: string;
30
+ transcript_path?: string;
31
+ permission_mode?: string;
32
+ cwd?: string;
33
+ // Mstro conversation context
26
34
  session_id?: string;
27
35
  conversation?: {
28
36
  messages?: Array<{
@@ -31,7 +39,7 @@ interface HookInput {
31
39
  }>;
32
40
  last_user_message?: string;
33
41
  };
34
- // Additional context fields Claude Code may provide
42
+ // Common fields
35
43
  tool_use_id?: string;
36
44
  working_directory?: string;
37
45
  }
@@ -48,7 +56,7 @@ async function readStdin(): Promise<string> {
48
56
  });
49
57
  }
50
58
 
51
- function buildOperationString(toolName: string, toolInput: Record<string, any>): string {
59
+ function buildOperationString(toolName: string, toolInput: Record<string, unknown>): string {
52
60
  if (toolName === 'Bash' && toolInput.command) {
53
61
  return `${toolName}: ${toolInput.command}`;
54
62
  }
@@ -59,6 +67,55 @@ function buildOperationString(toolName: string, toolInput: Record<string, any>):
59
67
  return `${toolName}: ${JSON.stringify(toolInput)}`;
60
68
  }
61
69
 
70
+ /**
71
+ * Detect whether the caller is Claude Code (vs mstro).
72
+ * Claude Code includes hook_event_name in its payload.
73
+ */
74
+ function isClaudeCodeHook(hookInput: HookInput): boolean {
75
+ return hookInput.hook_event_name === 'PreToolUse';
76
+ }
77
+
78
+ /**
79
+ * Format a bouncer decision for the calling system.
80
+ * Claude Code expects: { hookSpecificOutput: { permissionDecision, ... } }
81
+ * Mstro expects: { decision, reason, confidence, threatLevel, alternative }
82
+ */
83
+ function formatDecisionOutput(
84
+ decision: { decision: string; reasoning: string; confidence?: number; threatLevel?: string; alternative?: string },
85
+ claudeCode: boolean
86
+ ): string {
87
+ const mappedDecision = decision.decision === 'deny' ? 'deny' : 'allow';
88
+ if (claudeCode) {
89
+ return JSON.stringify({
90
+ hookSpecificOutput: {
91
+ hookEventName: 'PreToolUse',
92
+ permissionDecision: mappedDecision,
93
+ permissionDecisionReason: decision.reasoning,
94
+ },
95
+ });
96
+ }
97
+ return JSON.stringify({
98
+ decision: mappedDecision,
99
+ reason: decision.reasoning,
100
+ confidence: decision.confidence,
101
+ threatLevel: decision.threatLevel,
102
+ alternative: decision.alternative,
103
+ });
104
+ }
105
+
106
+ function formatSimpleOutput(d: 'allow' | 'deny', reason: string, claudeCode: boolean): string {
107
+ if (claudeCode) {
108
+ return JSON.stringify({
109
+ hookSpecificOutput: {
110
+ hookEventName: 'PreToolUse',
111
+ permissionDecision: d,
112
+ permissionDecisionReason: reason,
113
+ },
114
+ });
115
+ }
116
+ return JSON.stringify({ decision: d, reason });
117
+ }
118
+
62
119
  function extractConversationContext(hookInput: HookInput): string | undefined {
63
120
  const lastUserMessage = hookInput.conversation?.last_user_message;
64
121
  if (lastUserMessage) return `User's request: "${lastUserMessage}"`;
@@ -74,6 +131,7 @@ async function main() {
74
131
  const inputStr = await readStdin();
75
132
 
76
133
  if (!inputStr) {
134
+ // Can't detect caller without input — output both-compatible allow
77
135
  console.log(JSON.stringify({ decision: 'allow', reason: 'Empty input, allowing' }));
78
136
  process.exit(0);
79
137
  }
@@ -87,8 +145,10 @@ async function main() {
87
145
  process.exit(0);
88
146
  }
89
147
 
148
+ const claudeCode = isClaudeCodeHook(hookInput);
90
149
  const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
91
- const toolInput = hookInput.input || hookInput.toolInput || {};
150
+ // Claude Code: tool_input, mstro: input/toolInput
151
+ const toolInput = hookInput.tool_input || hookInput.input || hookInput.toolInput || {};
92
152
  const userRequestContext = extractConversationContext(hookInput);
93
153
  const lastUserMessage = hookInput.conversation?.last_user_message;
94
154
  const recentMessages = hookInput.conversation?.messages?.slice(-5);
@@ -97,7 +157,8 @@ async function main() {
97
157
  operation: buildOperationString(toolName, toolInput),
98
158
  context: {
99
159
  purpose: userRequestContext || 'Tool use request from Claude',
100
- workingDirectory: hookInput.working_directory || process.cwd(),
160
+ // Claude Code: cwd, mstro: working_directory
161
+ workingDirectory: hookInput.cwd || hookInput.working_directory || process.cwd(),
101
162
  toolName,
102
163
  toolInput,
103
164
  userRequest: lastUserMessage,
@@ -108,19 +169,11 @@ async function main() {
108
169
 
109
170
  try {
110
171
  const decision = await reviewOperation(bouncerRequest);
111
- console.log(JSON.stringify({
112
- decision: decision.decision === 'deny' ? 'deny' : 'allow',
113
- reason: decision.reasoning,
114
- confidence: decision.confidence,
115
- threatLevel: decision.threatLevel,
116
- alternative: decision.alternative,
117
- }));
118
- } catch (error: any) {
119
- console.error('[bouncer-cli] Error:', error.message);
120
- console.log(JSON.stringify({
121
- decision: 'allow',
122
- reason: `Bouncer error: ${error.message}. Allowing to avoid blocking.`
123
- }));
172
+ console.log(formatDecisionOutput(decision, claudeCode));
173
+ } catch (error: unknown) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ console.error('[bouncer-cli] Error:', message);
176
+ console.log(formatSimpleOutput('allow', `Bouncer error: ${message}. Allowing to avoid blocking.`, claudeCode));
124
177
  }
125
178
  }
126
179
 
@@ -42,6 +42,43 @@ import {
42
42
  SAFE_OPERATIONS
43
43
  } from './security-patterns.js';
44
44
 
45
+ /** Timeout for Haiku bouncer subprocess calls (ms). Configurable via env var. */
46
+ const HAIKU_TIMEOUT_MS = parseInt(process.env.BOUNCER_HAIKU_TIMEOUT_MS || '10000', 10);
47
+
48
+ // ========== Decision Cache ==========
49
+
50
+ /** Cache TTL in ms (default 5 minutes) */
51
+ const CACHE_TTL_MS = parseInt(process.env.BOUNCER_CACHE_TTL_MS || '300000', 10);
52
+ const CACHE_MAX_SIZE = 200;
53
+
54
+ interface CachedDecision {
55
+ decision: BouncerDecision;
56
+ expiresAt: number;
57
+ }
58
+
59
+ const decisionCache = new Map<string, CachedDecision>();
60
+
61
+ function getCachedDecision(operation: string): BouncerDecision | null {
62
+ const entry = decisionCache.get(operation);
63
+ if (!entry) return null;
64
+ if (Date.now() > entry.expiresAt) {
65
+ decisionCache.delete(operation);
66
+ return null;
67
+ }
68
+ return entry.decision;
69
+ }
70
+
71
+ function cacheDecision(operation: string, decision: BouncerDecision): void {
72
+ // Don't cache low-confidence or error-fallback decisions
73
+ if (decision.confidence < 50) return;
74
+ // Evict oldest entries if cache is full
75
+ if (decisionCache.size >= CACHE_MAX_SIZE) {
76
+ const firstKey = decisionCache.keys().next().value;
77
+ if (firstKey !== undefined) decisionCache.delete(firstKey);
78
+ }
79
+ decisionCache.set(operation, { decision, expiresAt: Date.now() + CACHE_TTL_MS });
80
+ }
81
+
45
82
  export interface BouncerReviewRequest {
46
83
  operation: string;
47
84
  context?: {
@@ -53,7 +90,7 @@ export interface BouncerReviewRequest {
53
90
  userRequest?: string;
54
91
  conversationHistory?: string[];
55
92
  sessionId?: string;
56
- [key: string]: any;
93
+ [key: string]: unknown;
57
94
  };
58
95
  }
59
96
 
@@ -98,7 +135,7 @@ function tryExtractJsonBlock(text: string): string {
98
135
  return text;
99
136
  }
100
137
 
101
- function validateDecision(parsed: any): BouncerDecision {
138
+ function validateDecision(parsed: Record<string, unknown>): BouncerDecision {
102
139
  if (!parsed || typeof parsed.decision !== 'string') {
103
140
  console.error('[Bouncer] Invalid parsed response:', parsed);
104
141
  throw new Error('Haiku returned invalid response: missing or invalid decision field');
@@ -111,11 +148,11 @@ function validateDecision(parsed: any): BouncerDecision {
111
148
  }
112
149
 
113
150
  return {
114
- decision: parsed.decision,
115
- confidence: parsed.confidence || 0,
116
- reasoning: parsed.reasoning || 'No reasoning provided',
117
- threatLevel: parsed.threat_level || 'medium',
118
- alternative: parsed.alternative
151
+ decision: parsed.decision as BouncerDecision['decision'],
152
+ confidence: (parsed.confidence as number) || 0,
153
+ reasoning: (parsed.reasoning as string) || 'No reasoning provided',
154
+ threatLevel: (parsed.threat_level as BouncerDecision['threatLevel']) || 'medium',
155
+ alternative: parsed.alternative as string | undefined
119
156
  };
120
157
  }
121
158
 
@@ -195,7 +232,7 @@ or
195
232
  const timer = setTimeout(() => {
196
233
  timedOut = true;
197
234
  child.kill('SIGTERM');
198
- }, 10000);
235
+ }, HAIKU_TIMEOUT_MS);
199
236
 
200
237
  child.stdout.on('data', (data) => {
201
238
  output += data.toString();
@@ -209,7 +246,7 @@ or
209
246
  clearTimeout(timer);
210
247
 
211
248
  if (timedOut) {
212
- reject(new Error('Haiku analysis timeout after 10s'));
249
+ reject(new Error(`Haiku analysis timed out after ${HAIKU_TIMEOUT_MS}ms`));
213
250
  return;
214
251
  }
215
252
 
@@ -221,9 +258,9 @@ or
221
258
  try {
222
259
  const decision = parseHaikuResponse(output.trim());
223
260
  resolve(decision);
224
- } catch (error: any) {
261
+ } catch (error: unknown) {
225
262
  console.error('[Bouncer] Parse error details:', error);
226
- reject(new Error(`Failed to parse Haiku response: ${error.message}`));
263
+ reject(new Error(`Failed to parse Haiku response: ${error instanceof Error ? error.message : String(error)}`));
227
264
  }
228
265
  });
229
266
 
@@ -245,6 +282,13 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
245
282
 
246
283
  const { operation } = request;
247
284
 
285
+ // Check cache first (pattern-layer decisions and prior Haiku results)
286
+ const cached = getCachedDecision(operation);
287
+ if (cached) {
288
+ console.error(`[Bouncer] ⚡ Cache hit: ${cached.decision} (${cached.confidence}%)`);
289
+ return cached;
290
+ }
291
+
248
292
  console.error('[Bouncer] Analyzing operation...');
249
293
  console.error(`[Bouncer] Operation: ${operation}`);
250
294
  if (request.context?.userRequest) {
@@ -276,6 +320,7 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
276
320
  { context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-noop', latencyMs }
277
321
  );
278
322
 
323
+ cacheDecision(operation, decision);
279
324
  return decision;
280
325
  }
281
326
 
@@ -312,6 +357,7 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
312
357
  latency_ms: latencyMs,
313
358
  });
314
359
 
360
+ cacheDecision(operation, decision);
315
361
  return decision;
316
362
  }
317
363
 
@@ -346,6 +392,7 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
346
392
  latency_ms: latencyMs,
347
393
  });
348
394
 
395
+ cacheDecision(operation, decision);
349
396
  return decision;
350
397
  }
351
398
 
@@ -381,6 +428,7 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
381
428
  latency_ms: latencyMs,
382
429
  });
383
430
 
431
+ cacheDecision(operation, decision);
384
432
  return decision;
385
433
  }
386
434
 
@@ -439,18 +487,53 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
439
487
  latency_ms: latencyMs,
440
488
  });
441
489
 
490
+ cacheDecision(operation, decision);
442
491
  return decision;
443
492
 
444
- } catch (error: any) {
493
+ } catch (error: unknown) {
445
494
  const latencyMs = Math.round(performance.now() - startTime);
446
- console.error(`[Bouncer] ⚠️ Haiku analysis failed: ${error.message}`);
495
+ const errorMessage = error instanceof Error ? error.message : String(error);
496
+ const isTimeout = errorMessage.includes('timed out');
497
+
498
+ if (isTimeout) {
499
+ // Timeout: default to ALLOW — prefer availability over security stall,
500
+ // since the user drove the interaction
501
+ console.error(`[Bouncer] ⚠️ Haiku analysis timed out after ${HAIKU_TIMEOUT_MS}ms — defaulting to ALLOW`);
502
+ captureException(error, { context: 'bouncer.haiku_timeout', operation });
503
+
504
+ const decision: BouncerDecision = {
505
+ decision: 'allow',
506
+ confidence: 50,
507
+ reasoning: `Security analysis timed out after ${HAIKU_TIMEOUT_MS}ms. Defaulting to allow — user initiated the action.`,
508
+ threatLevel: 'medium'
509
+ };
510
+
511
+ logBouncerDecision(
512
+ operation,
513
+ decision.decision,
514
+ decision.confidence,
515
+ decision.reasoning,
516
+ { context: request.context, threatLevel: decision.threatLevel, layer: 'haiku-timeout', latencyMs, error: errorMessage }
517
+ );
518
+ trackEvent(AnalyticsEvents.BOUNCER_TOOL_ALLOWED, {
519
+ layer: 'haiku-timeout',
520
+ operation_length: operation.length,
521
+ threat_level: 'medium',
522
+ confidence: 50,
523
+ latency_ms: latencyMs,
524
+ });
525
+
526
+ return decision;
527
+ }
528
+
529
+ console.error(`[Bouncer] ⚠️ Haiku analysis failed: ${errorMessage}`);
447
530
  captureException(error, { context: 'bouncer.haiku_analysis', operation });
448
531
 
449
- // Fail-safe: deny on AI failure
532
+ // Fail-safe: deny on non-timeout AI failure
450
533
  const decision: BouncerDecision = {
451
534
  decision: 'deny',
452
535
  confidence: 0,
453
- reasoning: `Security analysis failed: ${error.message}. Denying for safety.`,
536
+ reasoning: `Security analysis failed: ${errorMessage}. Denying for safety.`,
454
537
  threatLevel: 'critical'
455
538
  };
456
539
 
@@ -459,7 +542,7 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
459
542
  decision.decision,
460
543
  decision.confidence,
461
544
  decision.reasoning,
462
- { context: request.context, threatLevel: decision.threatLevel, layer: 'ai-error', latencyMs, error: error.message }
545
+ { context: request.context, threatLevel: decision.threatLevel, layer: 'ai-error', latencyMs, error: errorMessage }
463
546
  );
464
547
 
465
548
  return decision;
@@ -19,7 +19,7 @@ export interface AuditLogEntry {
19
19
  timestamp: string;
20
20
  sessionId?: string;
21
21
  operation: string;
22
- context?: any;
22
+ context?: unknown;
23
23
  decision: 'allow' | 'deny' | 'warn_allow';
24
24
  confidence: number;
25
25
  reasoning: string;
@@ -68,7 +68,7 @@ export class SecurityAuditLogger {
68
68
  confidence: number,
69
69
  reasoning: string,
70
70
  metadata?: {
71
- context?: any;
71
+ context?: unknown;
72
72
  threatLevel?: string;
73
73
  layer?: BouncerLayer;
74
74
  latencyMs?: number;
@@ -109,14 +109,14 @@ export function logBouncerDecision(
109
109
  decision: 'allow' | 'deny' | 'warn_allow' | undefined,
110
110
  confidence: number,
111
111
  reasoning: string,
112
- metadata?: any
112
+ metadata?: Record<string, unknown>
113
113
  ): void {
114
114
  // Defensive: handle undefined or invalid decision
115
115
  const safeDecision = decision ?? 'deny';
116
116
  const validDecisions = ['allow', 'deny', 'warn_allow'];
117
117
  const normalizedDecision = validDecisions.includes(safeDecision) ? safeDecision : 'deny';
118
118
 
119
- const workingDir = metadata?.context?.workingDirectory;
119
+ const workingDir = (metadata?.context as Record<string, unknown> | undefined)?.workingDirectory as string | undefined;
120
120
  const logger = getAuditLogger(workingDir);
121
121
  logger.logDecision(operation, normalizedDecision as 'allow' | 'deny' | 'warn_allow', confidence, reasoning, metadata);
122
122
 
@@ -73,7 +73,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
73
 
74
74
  const { tool_name, input } = request.params.arguments as {
75
75
  tool_name: string;
76
- input: Record<string, any>;
76
+ input: Record<string, unknown>;
77
77
  };
78
78
 
79
79
  console.error(`[MCP Bouncer] Analyzing ${tool_name} request...`);
@@ -84,7 +84,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
84
84
 
85
85
  // Extract file path with multiple property name support
86
86
  // Claude Code may use file_path, filePath, or path depending on context
87
- const getFilePath = (inp: Record<string, any>) =>
87
+ const getFilePath = (inp: Record<string, unknown>) =>
88
88
  inp.file_path || inp.filePath || inp.path;
89
89
 
90
90
  if (tool_name === 'Bash' && input.command) {
@@ -141,8 +141,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
141
141
  },
142
142
  ],
143
143
  };
144
- } catch (error: any) {
145
- console.error(`[MCP Bouncer] Error: ${error.message}`);
144
+ } catch (error: unknown) {
145
+ const errorMessage = error instanceof Error ? error.message : String(error);
146
+ console.error(`[MCP Bouncer] Error: ${errorMessage}`);
146
147
 
147
148
  // Fail-safe: deny on error
148
149
  return {
@@ -151,7 +152,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
151
152
  type: 'text',
152
153
  text: JSON.stringify({
153
154
  behavior: 'deny',
154
- message: `Security analysis failed: ${error.message}. Denying for safety.`,
155
+ message: `Security analysis failed: ${errorMessage}. Denying for safety.`,
155
156
  }),
156
157
  },
157
158
  ],
@@ -158,7 +158,7 @@ function getDistinctId(): string {
158
158
  /**
159
159
  * Get common properties included with all events
160
160
  */
161
- function getCommonProperties(): Record<string, any> {
161
+ function getCommonProperties(): Record<string, unknown> {
162
162
  return {
163
163
  os: platform(),
164
164
  arch: arch(),
@@ -171,7 +171,7 @@ function getCommonProperties(): Record<string, any> {
171
171
  /**
172
172
  * Track a custom event
173
173
  */
174
- export function trackEvent(event: string, properties?: Record<string, any>): void {
174
+ export function trackEvent(event: string, properties?: Record<string, unknown>): void {
175
175
  if (!client || !isTelemetryEnabled()) return
176
176
 
177
177
  client.capture({
@@ -187,7 +187,7 @@ export function trackEvent(event: string, properties?: Record<string, any>): voi
187
187
  /**
188
188
  * Identify a user (call after login)
189
189
  */
190
- export function identifyUser(userId: string, properties?: Record<string, any>): void {
190
+ export function identifyUser(userId: string, properties?: Record<string, unknown>): void {
191
191
  if (!client || !isTelemetryEnabled()) return
192
192
 
193
193
  // Link the client ID to the user ID