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.
- package/bin/mstro.js +65 -2
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +4 -3
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +2 -2
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +6 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +36 -4
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +3 -2
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +6 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.js +53 -14
- package/dist/server/mcp/bouncer-cli.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +70 -7
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +3 -3
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/server.js +3 -2
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -2
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/files.js +7 -7
- package/dist/server/services/files.js.map +1 -1
- package/dist/server/services/pathUtils.js +1 -1
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/platform.d.ts +2 -2
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sentry.d.ts +1 -1
- package/dist/server/services/sentry.d.ts.map +1 -1
- package/dist/server/services/sentry.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +10 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +32 -4
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/file-utils.d.ts +4 -0
- package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
- package/dist/server/services/websocket/file-utils.js +27 -8
- package/dist/server/services/websocket/file-utils.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +17 -17
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +3 -3
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.js +1 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +12 -11
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +1 -1
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.d.ts +22 -2
- package/dist/server/utils/agent-manager.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.js +2 -2
- package/dist/server/utils/agent-manager.js.map +1 -1
- package/dist/server/utils/port-manager.js.map +1 -1
- package/hooks/bouncer.sh +17 -3
- package/package.json +4 -2
- package/server/cli/headless/claude-invoker.ts +21 -16
- package/server/cli/headless/mcp-config.ts +8 -8
- package/server/cli/headless/runner.ts +32 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-session-manager.ts +8 -7
- package/server/index.ts +15 -9
- package/server/mcp/bouncer-cli.ts +73 -20
- package/server/mcp/bouncer-integration.ts +99 -16
- package/server/mcp/security-audit.ts +4 -4
- package/server/mcp/server.ts +6 -5
- package/server/services/analytics.ts +3 -3
- package/server/services/files.ts +13 -13
- package/server/services/pathUtils.ts +2 -2
- package/server/services/platform.ts +5 -5
- package/server/services/sentry.ts +1 -1
- package/server/services/terminal/pty-manager.ts +36 -9
- package/server/services/websocket/file-explorer-handlers.ts +1 -1
- package/server/services/websocket/file-utils.ts +28 -9
- package/server/services/websocket/git-handlers.ts +34 -34
- package/server/services/websocket/git-pr-handlers.ts +6 -6
- package/server/services/websocket/git-worktree-handlers.ts +20 -20
- package/server/services/websocket/handler.ts +2 -2
- package/server/services/websocket/session-handlers.ts +31 -30
- package/server/services/websocket/tab-handlers.ts +1 -1
- package/server/services/websocket/terminal-handlers.ts +2 -2
- package/server/services/websocket/types.ts +2 -0
- package/server/utils/agent-manager.ts +6 -6
- package/server/utils/port-manager.ts +1 -1
- package/server/cli/headless/output-utils.test.ts +0 -225
- package/server/cli/headless/stall-assessor.test.ts +0 -165
- package/server/cli/headless/tool-watchdog.test.ts +0 -429
- package/server/mcp/bouncer-integration.test.ts +0 -161
- package/server/mcp/security-patterns.test.ts +0 -258
- package/server/services/platform.test.ts +0 -1304
- package/server/services/websocket/autocomplete.test.ts +0 -194
- package/server/services/websocket/handler.test.ts +0 -20
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: ${
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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]:
|
|
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:
|
|
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
|
-
},
|
|
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(
|
|
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:
|
|
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:
|
|
493
|
+
} catch (error: unknown) {
|
|
445
494
|
const latencyMs = Math.round(performance.now() - startTime);
|
|
446
|
-
|
|
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: ${
|
|
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:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
|
package/server/mcp/server.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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:
|
|
145
|
-
|
|
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: ${
|
|
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,
|
|
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,
|
|
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,
|
|
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
|