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.
- package/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +305 -39
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +137 -30
- 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 +59 -4
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +20 -2
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +224 -31
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +6 -4
- 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 +13 -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 +13 -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 +12 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +81 -6
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- 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.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +68 -2329
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +508 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- 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 +7 -3
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +172 -43
- package/server/cli/headless/mcp-config.ts +8 -8
- package/server/cli/headless/runner.ts +57 -4
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +11 -2
- package/server/cli/improvisation-session-manager.ts +285 -37
- package/server/index.ts +15 -13
- package/server/mcp/README.md +59 -67
- 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 +16 -4
- package/server/services/files.ts +13 -13
- package/server/services/pathUtils.ts +2 -2
- package/server/services/platform.ts +17 -6
- package/server/services/sentry.ts +1 -1
- package/server/services/terminal/pty-manager.ts +88 -11
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/file-utils.ts +28 -9
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.ts +85 -2680
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +575 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +137 -0
- package/server/utils/agent-manager.ts +6 -6
- package/server/utils/port-manager.ts +1 -1
- package/bin/release.sh +0 -110
- package/server/services/platform.test.ts +0 -1304
- 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:
|
|
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 {
|
|
@@ -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
|
|
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:
|
|
363
|
+
let pendingRelayMessages: unknown[] = []
|
|
362
364
|
|
|
363
365
|
// Connect to platform
|
|
364
366
|
const platformConnection = new PlatformConnection(WORKING_DIR, {
|
package/server/mcp/README.md
CHANGED
|
@@ -1,105 +1,97 @@
|
|
|
1
|
-
# Mstro MCP Bouncer
|
|
1
|
+
# Mstro MCP Bouncer
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
11
|
+
- Regex-based pattern matching against consolidated threat/safe lists
|
|
12
|
+
|
|
13
|
+
### Layer 2: AI Analysis (~200-500ms, ~5% of operations)
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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**
|
|
27
|
-
- **bouncer-integration.ts**
|
|
28
|
-
- **security-patterns.ts**
|
|
29
|
-
- **security-audit.ts**
|
|
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
|
|
32
|
+
# From cli/ directory
|
|
37
33
|
npm run dev:mcp
|
|
38
34
|
|
|
39
|
-
# Or directly
|
|
40
|
-
|
|
35
|
+
# Or directly
|
|
36
|
+
npx tsx server/mcp/server.ts
|
|
41
37
|
```
|
|
42
38
|
|
|
43
|
-
###
|
|
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
|
-
|
|
41
|
+
The bouncer integrates via Claude Code's `--permission-prompt-tool` flag:
|
|
63
42
|
|
|
64
43
|
```bash
|
|
65
|
-
claude --print
|
|
66
|
-
--mcp-config mstro-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
88
|
-
- Git operations (
|
|
89
|
-
-
|
|
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
|
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
|