mstro-app 0.2.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 (153) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
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 {
@@ -290,10 +296,6 @@ async function startServer() {
290
296
 
291
297
  const PORT = await findAvailablePort(REQUESTED_PORT, 20)
292
298
 
293
- if (PORT !== REQUESTED_PORT) {
294
- console.log(`⚠️ Port ${REQUESTED_PORT} in use, using port ${PORT}`)
295
- }
296
-
297
299
  _currentInstance = instanceRegistry.register(PORT, WORKING_DIR)
298
300
 
299
301
  // Create HTTP server with Hono
@@ -303,7 +305,7 @@ async function startServer() {
303
305
  })
304
306
 
305
307
  // Create WebSocket server attached to the HTTP server
306
- const wss = new WebSocketServer({ server: server as any })
308
+ const wss = new WebSocketServer({ server: server as Server })
307
309
 
308
310
  wss.on('connection', (ws: NodeWebSocket, req: IncomingMessage) => {
309
311
  const url = new URL(req.url || '/', `http://localhost:${PORT}`)
@@ -358,7 +360,7 @@ async function startServer() {
358
360
 
359
361
  // Queue for messages that arrive before relay context is ready
360
362
  // This handles race conditions where initTab arrives before web_connected
361
- let pendingRelayMessages: any[] = []
363
+ let pendingRelayMessages: unknown[] = []
362
364
 
363
365
  // Connect to platform
364
366
  const platformConnection = new PlatformConnection(WORKING_DIR, {
@@ -1,105 +1,97 @@
1
- # Mstro MCP Bouncer Server
1
+ # Mstro MCP Bouncer
2
2
 
3
- This directory contains the Model Context Protocol (MCP) server implementation for Mstro v2's security bouncer.
4
-
5
- ## Overview
6
-
7
- The MCP bouncer server provides permission approval/denial for Claude Code tool use via the MCP protocol. It integrates with Mstro's security analysis system to review potentially risky operations before they execute.
3
+ MCP (Model Context Protocol) server that provides tool approval decisions for Claude Code. Intercepts tool calls and applies a 2-layer security system to allow or deny operations.
8
4
 
9
5
  ## Architecture
10
6
 
11
- The bouncer uses a 2-layer security system:
7
+ ### Layer 1: Pattern Matching (<5ms, ~95% of operations)
12
8
 
13
- ### Layer 1: Pattern-Based Fast Path (~95% of operations, <5ms)
14
9
  - **Critical threats** → Immediate DENY (99% confidence)
15
10
  - **Known-safe operations** → Immediate ALLOW (95% confidence)
16
- - Uses consolidated security patterns
11
+ - Regex-based pattern matching against consolidated threat/safe lists
12
+
13
+ ### Layer 2: AI Analysis (~200-500ms, ~5% of operations)
17
14
 
18
- ### Layer 2: Haiku AI Analysis (~5% of operations, 200-500ms)
19
- - Lightweight AI for ambiguous cases
20
- - Context-aware decisions with reasoning
21
- - Uses Claude Code headless pattern (spawn + stdin)
22
- - Variable confidence (50-90%)
15
+ - Spawns `claude --print --model haiku` for ambiguous cases
16
+ - Evaluates whether the operation looks like user-requested work or malicious injection
17
+ - Defaults to ALLOW — the assumption is the user is actively working with Claude
23
18
 
24
19
  ## Files
25
20
 
26
- - **server.ts** - Main MCP server entry point
27
- - **bouncer-integration.ts** - Core security review logic
28
- - **security-patterns.ts** - Pattern definitions for fast-path security checks
29
- - **security-audit.ts** - Audit logging system
21
+ - **server.ts** MCP server entry point. Exposes single `approval_prompt` tool via stdio transport.
22
+ - **bouncer-integration.ts** Core 2-layer security review logic. Orchestrates pattern check → AI analysis flow.
23
+ - **security-patterns.ts** Pattern definitions: CRITICAL_THREATS, SAFE_OPERATIONS, NEEDS_AI_REVIEW, SENSITIVE_PATHS.
24
+ - **security-audit.ts** Audit logging to `~/.mstro/logs/bouncer-audit.jsonl` (JSON Lines format).
25
+ - **bouncer-cli.ts** — Shell-callable wrapper invoked by `~/.claude/hooks/bouncer.sh`. Reads JSON from stdin, outputs decision to stdout.
30
26
 
31
27
  ## Usage
32
28
 
33
29
  ### Starting the Server
34
30
 
35
31
  ```bash
36
- # From mstro-v2 root directory
32
+ # From cli/ directory
37
33
  npm run dev:mcp
38
34
 
39
- # Or directly with bun
40
- bun run server/mcp/server.ts
35
+ # Or directly
36
+ npx tsx server/mcp/server.ts
41
37
  ```
42
38
 
43
- ### Configuration
44
-
45
- The server is configured via `mstro-bouncer-mcp.json` in the project root:
46
-
47
- ```json
48
- {
49
- "mcpServers": {
50
- "mstro-bouncer": {
51
- "command": "bun",
52
- "args": ["run", "server/mcp/server.ts"],
53
- "description": "Mstro security bouncer for approving/denying Claude Code tool use",
54
- "env": {
55
- "BOUNCER_USE_AI": "true"
56
- }
57
- }
58
- }
59
- }
60
- ```
39
+ ### Integration with Claude Code
61
40
 
62
- ### Using with Claude Code
41
+ The bouncer integrates via Claude Code's `--permission-prompt-tool` flag:
63
42
 
64
43
  ```bash
65
- claude --print --permission-prompt-tool mcp__mstro-bouncer__approval_prompt \
66
- --mcp-config mstro-bouncer-mcp.json \
44
+ claude --print \
45
+ --mcp-config ~/.mstro/mcp-config.json \
46
+ --permission-prompt-tool mcp__mstro-bouncer__approval_prompt \
67
47
  "your prompt here"
68
48
  ```
69
49
 
50
+ The MCP config is auto-generated by the headless runner at `~/.mstro/mcp-config.json`. It includes the bouncer server plus any user-configured MCP servers from `~/.claude.json`.
51
+
52
+ ### Hook Integration
53
+
54
+ When installed via `mstro configure-hooks`, a shell script at `~/.claude/hooks/bouncer.sh` calls `bouncer-cli.ts` as a PreToolUse hook. This provides security for both mstro sessions and standalone Claude Code usage.
55
+
70
56
  ## Environment Variables
71
57
 
72
- - **BOUNCER_USE_AI** - Enable/disable AI analysis (default: `true`)
73
- - Set to `false` to use only pattern-based checks
74
- - **CLAUDE_COMMAND** - Claude CLI command (default: `claude`)
58
+ | Variable | Default | Description |
59
+ |----------|---------|-------------|
60
+ | `BOUNCER_USE_AI` | `true` | Enable AI analysis layer. Set `false` for pattern-only checks. |
61
+ | `CLAUDE_COMMAND` | `claude` | Claude CLI command path |
75
62
 
76
63
  ## Security Patterns
77
64
 
78
65
  ### Critical Threats (Auto-deny)
79
- - Root/home directory deletion (`rm -rf / or ~`)
66
+
67
+ - Root/home directory deletion (`rm -rf /` or `rm -rf ~`)
80
68
  - Fork bombs
81
- - Disk device overwrites
82
- - Filesystem formatting
69
+ - Disk device overwrites (`dd if=/dev/zero of=/dev/sd*`)
70
+ - Filesystem formatting (`mkfs`)
83
71
  - Obfuscated code execution
72
+ - System directory permission removal
84
73
 
85
74
  ### Safe Operations (Auto-allow)
75
+
86
76
  - Read/Glob/Grep operations
87
- - Common package manager commands (`npm install`, `yarn build`)
88
- - Git operations (`status`, `log`, `diff`)
89
- - Safe file deletions (`node_modules`, `dist`, `build`)
77
+ - Package manager commands (npm, yarn, pnpm, bun, cargo, go)
78
+ - Git operations (status, log, diff, branch, clone, pull, checkout)
79
+ - Docker operations
80
+ - File operations within home/tmp directories
81
+ - Safe artifact deletions (node_modules, dist, build, .cache)
90
82
 
91
83
  ### Requires AI Review
92
- - Pipe-to-shell from remote sources
93
- - Sudo operations
94
- - Writing executable files
95
- - System directory modifications
96
- - Custom script execution
84
+
85
+ - Pipe-to-shell from remote sources (`curl | bash`)
86
+ - sudo operations
87
+ - `rm -rf` (except safe artifacts)
88
+ - Write/Edit operations (except /tmp)
89
+ - Variable expansion/globs in Bash
97
90
 
98
91
  ## Audit Logging
99
92
 
100
- All security decisions are logged to `./logs/security/bouncer-audit.jsonl` in JSON Lines format.
93
+ All decisions are logged to `~/.mstro/logs/bouncer-audit.jsonl`:
101
94
 
102
- Example log entry:
103
95
  ```json
104
96
  {
105
97
  "timestamp": "2025-11-15T12:00:00.000Z",
@@ -107,16 +99,16 @@ Example log entry:
107
99
  "decision": "allow",
108
100
  "confidence": 95,
109
101
  "reasoning": "Operation matches known-safe patterns",
110
- "threatLevel": "low"
102
+ "threatLevel": "low",
103
+ "layer": "pattern-safe",
104
+ "latencyMs": 2
111
105
  }
112
106
  ```
113
107
 
114
- ## Performance
115
-
116
- - 95%+ operations resolve in <5ms (Layer 1)
117
- - 5% require AI analysis (~200-500ms)
118
- - No ANTHROPIC_API_KEY required - uses existing Claude installation
119
-
120
- ## Integration
108
+ ## Design Principles
121
109
 
122
- The MCP server runs separately from the web application servers and is only needed when using Claude Code's permission prompts feature. It does NOT auto-start with `npm start` or the web servers.
110
+ - **Protect against injection, not dangerous commands** operations are user-requested by default
111
+ - **Fast path first** — 95%+ decisions resolve in <5ms via pattern matching
112
+ - **No API key required** — uses existing `claude` CLI installation for AI layer
113
+ - **Fail-safe** — denies on analysis errors (conservative)
114
+ - **Graceful degradation** — falls back to pattern-only if AI unavailable
@@ -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