mstro-app 0.3.6 → 0.3.8

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 (74) hide show
  1. package/README.md +4 -8
  2. package/bin/mstro.js +54 -15
  3. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker.js +10 -6
  5. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  6. package/dist/server/cli/headless/runner.d.ts +6 -1
  7. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  8. package/dist/server/cli/headless/runner.js +20 -10
  9. package/dist/server/cli/headless/runner.js.map +1 -1
  10. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.js +4 -1
  12. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  13. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  14. package/dist/server/cli/headless/tool-watchdog.js +8 -0
  15. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.d.ts +8 -1
  17. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  18. package/dist/server/cli/improvisation-session-manager.js +45 -3
  19. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  20. package/dist/server/index.js +0 -4
  21. package/dist/server/index.js.map +1 -1
  22. package/dist/server/mcp/bouncer-integration.d.ts +2 -0
  23. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  24. package/dist/server/mcp/bouncer-integration.js +55 -39
  25. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  26. package/dist/server/mcp/bouncer-sandbox.d.ts +60 -0
  27. package/dist/server/mcp/bouncer-sandbox.d.ts.map +1 -0
  28. package/dist/server/mcp/bouncer-sandbox.js +182 -0
  29. package/dist/server/mcp/bouncer-sandbox.js.map +1 -0
  30. package/dist/server/mcp/security-patterns.d.ts +6 -12
  31. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  32. package/dist/server/mcp/security-patterns.js +197 -10
  33. package/dist/server/mcp/security-patterns.js.map +1 -1
  34. package/dist/server/services/platform.d.ts +6 -4
  35. package/dist/server/services/platform.d.ts.map +1 -1
  36. package/dist/server/services/platform.js +30 -11
  37. package/dist/server/services/platform.js.map +1 -1
  38. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  39. package/dist/server/services/terminal/pty-manager.js +3 -1
  40. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  41. package/dist/server/services/websocket/handler.d.ts +0 -1
  42. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  43. package/dist/server/services/websocket/handler.js +7 -2
  44. package/dist/server/services/websocket/handler.js.map +1 -1
  45. package/dist/server/services/websocket/quality-handlers.d.ts +4 -0
  46. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -0
  47. package/dist/server/services/websocket/quality-handlers.js +106 -0
  48. package/dist/server/services/websocket/quality-handlers.js.map +1 -0
  49. package/dist/server/services/websocket/quality-service.d.ts +54 -0
  50. package/dist/server/services/websocket/quality-service.d.ts.map +1 -0
  51. package/dist/server/services/websocket/quality-service.js +766 -0
  52. package/dist/server/services/websocket/quality-service.js.map +1 -0
  53. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  54. package/dist/server/services/websocket/session-handlers.js +23 -0
  55. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  56. package/dist/server/services/websocket/types.d.ts +2 -2
  57. package/dist/server/services/websocket/types.d.ts.map +1 -1
  58. package/package.json +2 -1
  59. package/server/cli/headless/claude-invoker.ts +7 -5
  60. package/server/cli/headless/runner.ts +17 -4
  61. package/server/cli/headless/stall-assessor.ts +4 -1
  62. package/server/cli/headless/tool-watchdog.ts +8 -0
  63. package/server/cli/improvisation-session-manager.ts +50 -3
  64. package/server/index.ts +0 -4
  65. package/server/mcp/bouncer-integration.ts +66 -44
  66. package/server/mcp/bouncer-sandbox.ts +214 -0
  67. package/server/mcp/security-patterns.ts +206 -10
  68. package/server/services/platform.ts +29 -11
  69. package/server/services/terminal/pty-manager.ts +3 -1
  70. package/server/services/websocket/handler.ts +7 -2
  71. package/server/services/websocket/quality-handlers.ts +140 -0
  72. package/server/services/websocket/quality-service.ts +922 -0
  73. package/server/services/websocket/session-handlers.ts +26 -0
  74. package/server/services/websocket/types.ts +14 -0
@@ -38,6 +38,7 @@ import { captureException } from '../services/sentry.js';
38
38
  import {
39
39
  CRITICAL_THREATS,
40
40
  matchesPattern,
41
+ normalizeOperation,
41
42
  requiresAIReview,
42
43
  SAFE_OPERATIONS
43
44
  } from './security-patterns.js';
@@ -68,6 +69,11 @@ function getCachedDecision(operation: string): BouncerDecision | null {
68
69
  return entry.decision;
69
70
  }
70
71
 
72
+ /** Clear the decision cache. Exposed for testing statistical reliability (multiple runs per operation). */
73
+ export function clearDecisionCache(): void {
74
+ decisionCache.clear();
75
+ }
76
+
71
77
  function cacheDecision(operation: string, decision: BouncerDecision): void {
72
78
  // Don't cache low-confidence or error-fallback decisions
73
79
  if (decision.confidence < 50) return;
@@ -304,13 +310,54 @@ function finalizeDecision(
304
310
  return decision;
305
311
  }
306
312
 
313
+ /**
314
+ * Layer 2: Haiku AI analysis with timeout/error handling.
315
+ */
316
+ async function runHaikuAnalysis(
317
+ request: BouncerReviewRequest,
318
+ operation: string,
319
+ startTime: number,
320
+ fin: (d: BouncerDecision, layer: string, opts?: Parameters<typeof finalizeDecision>[6]) => BouncerDecision,
321
+ ): Promise<BouncerDecision> {
322
+ if (process.env.BOUNCER_USE_AI === 'false') {
323
+ console.error('[Bouncer] AI analysis disabled (BOUNCER_USE_AI=false)');
324
+ return fin({ decision: 'warn_allow', confidence: 60, reasoning: 'Operation requires review but AI analysis is disabled. Proceeding with caution.', threatLevel: 'medium' }, 'ai-disabled', { skipCache: true, skipAnalytics: true });
325
+ }
326
+
327
+ console.error('[Bouncer] 🤖 Invoking Haiku for AI analysis...');
328
+ trackEvent(AnalyticsEvents.BOUNCER_HAIKU_REVIEW, { operation_length: operation.length });
329
+
330
+ const claudeCommand = process.env.CLAUDE_COMMAND || 'claude';
331
+ const workingDir = request.context?.workingDirectory || process.cwd();
332
+
333
+ try {
334
+ const decision = await analyzeWithHaiku(request, claudeCommand, workingDir);
335
+ console.error(`[Bouncer] ✓ Haiku decision: ${decision.decision} (${decision.confidence}% confidence) [${Math.round(performance.now() - startTime)}ms]`);
336
+ console.error(`[Bouncer] Reasoning: ${decision.reasoning}`);
337
+ return fin(decision, 'haiku-ai');
338
+ } catch (error: unknown) {
339
+ const errorMessage = error instanceof Error ? error.message : String(error);
340
+
341
+ if (errorMessage.includes('timed out')) {
342
+ console.error(`[Bouncer] ⚠️ Haiku analysis timed out after ${HAIKU_TIMEOUT_MS}ms — defaulting to ALLOW`);
343
+ captureException(error, { context: 'bouncer.haiku_timeout', operation });
344
+ return fin({ decision: 'allow', confidence: 50, reasoning: `Security analysis timed out after ${HAIKU_TIMEOUT_MS}ms. Defaulting to allow — user initiated the action.`, threatLevel: 'medium' }, 'haiku-timeout', { skipCache: true });
345
+ }
346
+
347
+ console.error(`[Bouncer] ⚠️ Haiku analysis failed: ${errorMessage}`);
348
+ captureException(error, { context: 'bouncer.haiku_analysis', operation });
349
+ return fin({ decision: 'deny', confidence: 0, reasoning: `Security analysis failed: ${errorMessage}. Denying for safety.`, threatLevel: 'critical' }, 'ai-error', { skipCache: true, skipAnalytics: true, error: errorMessage });
350
+ }
351
+ }
352
+
307
353
  /**
308
354
  * Main bouncer review function - 2-layer hybrid system
309
355
  */
310
356
  export async function reviewOperation(request: BouncerReviewRequest): Promise<BouncerDecision> {
311
357
  const { logBouncerDecision } = await import('./security-audit.js');
312
358
  const startTime = performance.now();
313
- const { operation } = request;
359
+ const { operation: rawOperation } = request;
360
+ const operation = normalizeOperation(rawOperation);
314
361
  const fin = (d: BouncerDecision, layer: string, opts?: Parameters<typeof finalizeDecision>[6]) =>
315
362
  finalizeDecision(operation, d, layer, startTime, request.context, logBouncerDecision, opts);
316
363
 
@@ -336,15 +383,9 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
336
383
 
337
384
  // LAYER 1: Pattern-Based Fast Path (< 5ms)
338
385
 
339
- // Check safe operations FIRST allows trusted sources (e.g., brew, rustup)
340
- // to pass before hitting critical threat patterns like curl|bash
341
- const safeOperation = matchesPattern(operation, SAFE_OPERATIONS);
342
- if (safeOperation) {
343
- console.error('[Bouncer] ⚡ Fast path: Safe operation approved');
344
- return fin({ decision: 'allow', confidence: 95, reasoning: 'Operation matches known-safe patterns. No security concerns detected.', threatLevel: 'low' }, 'pattern-safe');
345
- }
346
-
347
- // Critical threats (rm -rf /, fork bombs) — ALWAYS denied
386
+ // Critical threats (rm -rf /, fork bombs) ALWAYS denied, checked first
387
+ // to prevent chained commands (e.g., "echo hello; rm -rf /") from bypassing
388
+ // via a safe prefix match.
348
389
  const criticalThreat = matchesPattern(operation, CRITICAL_THREATS);
349
390
  if (criticalThreat) {
350
391
  console.error('[Bouncer] ⚡ Fast path: CRITICAL THREAT detected');
@@ -355,43 +396,24 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
355
396
  }, 'pattern-critical');
356
397
  }
357
398
 
358
- // LAYER 2: Haiku AI Analysis (~200-500ms)
359
-
360
- // Default allow for operations that don't need AI review
399
+ // Use requiresAIReview() for nuanced routing — handles sensitive paths,
400
+ // safe operations with guards (chain operators, pipes, expansion), and
401
+ // exfiltration patterns in a single consistent check.
361
402
  if (!requiresAIReview(operation)) {
362
- console.error('[Bouncer] Fast path: No concerning patterns, allowing');
363
- return fin({ decision: 'allow', confidence: 80, reasoning: 'Operation appears safe based on pattern analysis. No obvious threats detected.', threatLevel: 'low' }, 'pattern-default');
364
- }
365
-
366
- if (process.env.BOUNCER_USE_AI === 'false') {
367
- console.error('[Bouncer] AI analysis disabled (BOUNCER_USE_AI=false)');
368
- return fin({ decision: 'warn_allow', confidence: 60, reasoning: 'Operation requires review but AI analysis is disabled. Proceeding with caution.', threatLevel: 'medium' }, 'ai-disabled', { skipCache: true, skipAnalytics: true });
403
+ const isSafe = matchesPattern(operation, SAFE_OPERATIONS);
404
+ console.error(`[Bouncer] Fast path: ${isSafe ? 'Safe operation approved' : 'No concerning patterns, allowing'}`);
405
+ return fin({
406
+ decision: 'allow',
407
+ confidence: isSafe ? 95 : 80,
408
+ reasoning: isSafe
409
+ ? 'Operation matches known-safe patterns. No security concerns detected.'
410
+ : 'Operation appears safe based on pattern analysis. No obvious threats detected.',
411
+ threatLevel: 'low'
412
+ }, isSafe ? 'pattern-safe' : 'pattern-default');
369
413
  }
370
414
 
371
- console.error('[Bouncer] 🤖 Invoking Haiku for AI analysis...');
372
- trackEvent(AnalyticsEvents.BOUNCER_HAIKU_REVIEW, { operation_length: operation.length });
373
-
374
- const claudeCommand = process.env.CLAUDE_COMMAND || 'claude';
375
- const workingDir = request.context?.workingDirectory || process.cwd();
376
-
377
- try {
378
- const decision = await analyzeWithHaiku(request, claudeCommand, workingDir);
379
- console.error(`[Bouncer] ✓ Haiku decision: ${decision.decision} (${decision.confidence}% confidence) [${Math.round(performance.now() - startTime)}ms]`);
380
- console.error(`[Bouncer] Reasoning: ${decision.reasoning}`);
381
- return fin(decision, 'haiku-ai');
382
- } catch (error: unknown) {
383
- const errorMessage = error instanceof Error ? error.message : String(error);
384
-
385
- if (errorMessage.includes('timed out')) {
386
- console.error(`[Bouncer] ⚠️ Haiku analysis timed out after ${HAIKU_TIMEOUT_MS}ms — defaulting to ALLOW`);
387
- captureException(error, { context: 'bouncer.haiku_timeout', operation });
388
- return fin({ decision: 'allow', confidence: 50, reasoning: `Security analysis timed out after ${HAIKU_TIMEOUT_MS}ms. Defaulting to allow — user initiated the action.`, threatLevel: 'medium' }, 'haiku-timeout', { skipCache: true });
389
- }
390
-
391
- console.error(`[Bouncer] ⚠️ Haiku analysis failed: ${errorMessage}`);
392
- captureException(error, { context: 'bouncer.haiku_analysis', operation });
393
- return fin({ decision: 'deny', confidence: 0, reasoning: `Security analysis failed: ${errorMessage}. Denying for safety.`, threatLevel: 'critical' }, 'ai-error', { skipCache: true, skipAnalytics: true, error: errorMessage });
394
- }
415
+ // LAYER 2: Haiku AI Analysis (~200-500ms)
416
+ return runHaikuAnalysis(request, operation, startTime, fin);
395
417
  }
396
418
 
397
419
  /**
@@ -0,0 +1,214 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Sandbox Harness for Bouncer Testing
6
+ *
7
+ * Wraps command execution in Anthropic's sandbox-runtime (bubblewrap on Linux,
8
+ * sandbox-exec on macOS) to safely test what happens when the bouncer FAILS —
9
+ * i.e., when a malicious tool call gets through.
10
+ *
11
+ * Usage in tests:
12
+ * const harness = new BouncerSandboxHarness();
13
+ * await harness.initialize();
14
+ * const result = await harness.executeInSandbox('rm -rf /tmp/test-canary');
15
+ * expect(result.violations).toContain(...)
16
+ * await harness.cleanup();
17
+ */
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ export interface SandboxExecResult {
25
+ /** The sandboxed command that was actually run */
26
+ wrappedCommand: string;
27
+ /** Whether sandbox-runtime is available on this platform */
28
+ sandboxAvailable: boolean;
29
+ /** Whether the sandbox contained the operation (no violations) */
30
+ contained: boolean;
31
+ /** List of violation descriptions if any escaped the sandbox */
32
+ violations: string[];
33
+ }
34
+
35
+ export interface CanaryCheckResult {
36
+ /** Whether the canary file still exists (should be true if sandbox contained the write) */
37
+ canaryIntact: boolean;
38
+ /** Whether a file was written outside the sandbox (should be false) */
39
+ escapeDetected: boolean;
40
+ }
41
+
42
+ /**
43
+ * Test harness that wraps command execution in sandbox-runtime.
44
+ * Provides canary files and violation tracking to verify containment.
45
+ */
46
+ export class BouncerSandboxHarness {
47
+ private sandboxManager: Awaited<typeof import('@anthropic-ai/sandbox-runtime')>['SandboxManager'] | null = null;
48
+ private sandboxAvailable = false;
49
+ private tempDir: string;
50
+ private canaryDir: string;
51
+
52
+ constructor() {
53
+ this.tempDir = mkdtempSync(join(tmpdir(), 'bouncer-sandbox-'));
54
+ this.canaryDir = join(this.tempDir, 'canaries');
55
+ mkdirSync(this.canaryDir, { recursive: true });
56
+ }
57
+
58
+ /**
59
+ * Initialize the sandbox. Falls back gracefully if bwrap/sandbox-exec not available.
60
+ */
61
+ async initialize(): Promise<{ available: boolean; reason?: string }> {
62
+ try {
63
+ const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
64
+
65
+ if (!SandboxManager.isSupportedPlatform()) {
66
+ return { available: false, reason: 'Platform not supported by sandbox-runtime' };
67
+ }
68
+
69
+ const deps = SandboxManager.checkDependencies();
70
+ if (deps.errors.length > 0) {
71
+ return {
72
+ available: false,
73
+ reason: `Missing dependencies: ${deps.errors.join(', ')}`,
74
+ };
75
+ }
76
+
77
+ await SandboxManager.initialize({
78
+ network: {
79
+ allowedDomains: [], // Block ALL network access
80
+ deniedDomains: ['*'],
81
+ },
82
+ filesystem: {
83
+ denyRead: [
84
+ '/home/*/.ssh',
85
+ '/home/*/.aws',
86
+ '/home/*/.gnupg',
87
+ '/etc/shadow',
88
+ '/etc/passwd',
89
+ ],
90
+ allowWrite: [this.tempDir], // Only allow writes to our temp dir
91
+ denyWrite: [
92
+ '/',
93
+ '/home',
94
+ '/etc',
95
+ '/usr',
96
+ '/var',
97
+ ],
98
+ },
99
+ });
100
+
101
+ this.sandboxManager = SandboxManager;
102
+ this.sandboxAvailable = true;
103
+ return { available: true };
104
+ } catch (error: unknown) {
105
+ const msg = error instanceof Error ? error.message : String(error);
106
+ return { available: false, reason: `Failed to initialize sandbox: ${msg}` };
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Execute a command inside the sandbox. Returns containment results.
112
+ * If sandbox is not available, validates the bouncer decision only (no actual execution).
113
+ */
114
+ async executeInSandbox(command: string): Promise<SandboxExecResult> {
115
+ if (!this.sandboxAvailable || !this.sandboxManager) {
116
+ return {
117
+ wrappedCommand: command,
118
+ sandboxAvailable: false,
119
+ contained: true,
120
+ violations: ['Sandbox not available — decision-only testing mode'],
121
+ };
122
+ }
123
+
124
+ const violations: string[] = [];
125
+ try {
126
+ const wrappedCommand = await this.sandboxManager.wrapWithSandbox(command);
127
+
128
+ // Execute the wrapped command and capture violations
129
+ try {
130
+ execSync(wrappedCommand, {
131
+ timeout: 5000,
132
+ stdio: 'pipe',
133
+ cwd: this.tempDir,
134
+ });
135
+ } catch {
136
+ // Command failure inside sandbox is expected for malicious ops
137
+ }
138
+
139
+ // Check violation store
140
+ const stderr = this.sandboxManager.annotateStderrWithSandboxFailures(command, '');
141
+ if (stderr) {
142
+ violations.push(stderr);
143
+ }
144
+
145
+ this.sandboxManager.cleanupAfterCommand();
146
+
147
+ return {
148
+ wrappedCommand,
149
+ sandboxAvailable: true,
150
+ contained: violations.length === 0,
151
+ violations,
152
+ };
153
+ } catch (error: unknown) {
154
+ const msg = error instanceof Error ? error.message : String(error);
155
+ violations.push(`Sandbox execution error: ${msg}`);
156
+ return {
157
+ wrappedCommand: command,
158
+ sandboxAvailable: true,
159
+ contained: true, // Error means the command didn't execute
160
+ violations,
161
+ };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Place a canary file and return a checker to verify containment.
167
+ * If a sandboxed command can delete or modify the canary, containment failed.
168
+ */
169
+ placeCanary(name: string): { path: string; check: () => CanaryCheckResult } {
170
+ const canaryPath = join(this.canaryDir, name);
171
+ const escapePath = join(this.canaryDir, `${name}.escaped`);
172
+ writeFileSync(canaryPath, `canary-${Date.now()}`, 'utf-8');
173
+
174
+ return {
175
+ path: canaryPath,
176
+ check: () => ({
177
+ canaryIntact: existsSync(canaryPath),
178
+ escapeDetected: existsSync(escapePath),
179
+ }),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Get the temp directory where sandboxed commands can write.
185
+ */
186
+ getSandboxWriteDir(): string {
187
+ return this.tempDir;
188
+ }
189
+
190
+ /**
191
+ * Whether the sandbox is actually available and initialized.
192
+ */
193
+ isAvailable(): boolean {
194
+ return this.sandboxAvailable;
195
+ }
196
+
197
+ /**
198
+ * Clean up temp dirs and reset sandbox state.
199
+ */
200
+ async cleanup(): Promise<void> {
201
+ try {
202
+ if (this.sandboxManager) {
203
+ await this.sandboxManager.reset();
204
+ }
205
+ } catch {
206
+ // Ignore cleanup errors
207
+ }
208
+ try {
209
+ rmSync(this.tempDir, { recursive: true, force: true });
210
+ } catch {
211
+ // Ignore cleanup errors
212
+ }
213
+ }
214
+ }
@@ -14,6 +14,8 @@
14
14
  * - The question is: "Does this operation make sense given user intent?"
15
15
  */
16
16
 
17
+ import { resolve } from 'node:path';
18
+
17
19
  export interface SecurityPattern {
18
20
  pattern: RegExp;
19
21
  reason?: string;
@@ -83,7 +85,16 @@ export const CRITICAL_THREATS: SecurityPattern[] = [
83
85
  {
84
86
  pattern: /chmod\s+000\s+\//i,
85
87
  reason: 'Attempting to make system directories inaccessible'
86
- }
88
+ },
89
+ // Reverse shells - never legitimate in a dev workflow
90
+ {
91
+ pattern: /\/dev\/tcp\//i,
92
+ reason: 'Reverse shell via /dev/tcp - classic backdoor technique'
93
+ },
94
+ {
95
+ pattern: /\bnc\b.*-[elp].*\b\d+\b/i,
96
+ reason: 'Netcat listener/reverse shell - common backdoor technique'
97
+ },
87
98
  // NOTE: curl|bash is NOT here - it goes to Haiku for context review
88
99
  // The question is "did a bad actor inject this?" not "is curl|bash dangerous?"
89
100
  ];
@@ -158,12 +169,104 @@ export const NEEDS_AI_REVIEW: SecurityPattern[] = [
158
169
  reason: 'Recursive deletion - verify target matches user intent'
159
170
  },
160
171
 
172
+ // Data exfiltration patterns — piping data to network tools
173
+ {
174
+ pattern: /\|\s*(nc|netcat|ncat)\b/i,
175
+ reason: 'Pipe to netcat - potential data exfiltration'
176
+ },
177
+ {
178
+ pattern: /\bscp\b.*@/i,
179
+ reason: 'SCP to remote host - potential data exfiltration'
180
+ },
181
+ {
182
+ pattern: /\|\s*curl\b/i,
183
+ reason: 'Pipe to curl - potential data exfiltration'
184
+ },
185
+ {
186
+ pattern: /curl\b.*-d\s*@/i,
187
+ reason: 'Curl with file upload - potential data exfiltration'
188
+ },
189
+
161
190
  // ALL Write/Edit operations that aren't to /tmp go through context review
162
191
  // This is the key change: we review based on context, not blanket allow/deny
163
192
  {
164
193
  pattern: /^(Write|Edit):\s*(?!\/tmp\/|\/var\/tmp\/)/i,
165
194
  reason: 'File modification - verify aligns with user request'
166
195
  },
196
+
197
+ // Reverse shells and bind shells — network-connected interactive shells
198
+ {
199
+ pattern: /\/dev\/tcp\//i,
200
+ reason: 'Potential reverse shell via /dev/tcp'
201
+ },
202
+ {
203
+ pattern: /\b(nc|netcat|ncat)\b.*-e\s/i,
204
+ reason: 'Netcat with -e flag - potential reverse shell'
205
+ },
206
+ {
207
+ pattern: /\bsocket\b.*\bconnect\b.*\b(dup2|subprocess|exec)\b/i,
208
+ reason: 'Programmatic reverse shell pattern (socket+connect+exec)'
209
+ },
210
+ {
211
+ pattern: /\bperl\b.*\bsocket\b.*\bexec\b/i,
212
+ reason: 'Perl reverse shell pattern'
213
+ },
214
+
215
+ // Encoded/obfuscated payloads piped to shell or eval
216
+ {
217
+ pattern: /\b(base64|base32)\b.*-d.*\|\s*(bash|sh)\b/i,
218
+ reason: 'Decoded payload piped to shell - obfuscated command execution'
219
+ },
220
+ {
221
+ pattern: /\\x[0-9a-f]{2}.*\|\s*(bash|sh)\b/i,
222
+ reason: 'Hex-encoded payload piped to shell'
223
+ },
224
+ {
225
+ pattern: /\bexec\b.*\b(base64|b64decode)\b/i,
226
+ reason: 'Exec with base64 decoding - obfuscated code execution'
227
+ },
228
+ {
229
+ pattern: /\bprintf\b.*\\x[0-9a-f].*\|\s*(bash|sh)\b/i,
230
+ reason: 'Printf hex payload piped to shell'
231
+ },
232
+
233
+ // Cloud metadata / SSRF — accessing cloud instance credentials
234
+ {
235
+ pattern: /169\.254\.169\.254/i,
236
+ reason: 'AWS/Azure IMDS access - potential credential theft'
237
+ },
238
+ {
239
+ pattern: /metadata\.google\.internal/i,
240
+ reason: 'GCP metadata access - potential credential theft'
241
+ },
242
+
243
+ // Persistence — writing to shell profiles, cron, authorized_keys via echo/append
244
+ {
245
+ pattern: />>\s*~?\/?.*\/(authorized_keys|\.bashrc|\.bash_profile|\.zshrc|\.profile)/i,
246
+ reason: 'Appending to sensitive file - potential persistence mechanism'
247
+ },
248
+ {
249
+ pattern: /\bld\.so\.preload\b/i,
250
+ reason: 'LD_PRELOAD injection - shared library hijacking'
251
+ },
252
+
253
+ // wget with file upload
254
+ {
255
+ pattern: /wget\b.*--post-file/i,
256
+ reason: 'wget file upload - potential data exfiltration'
257
+ },
258
+
259
+ // pip install from custom index (supply chain attack)
260
+ {
261
+ pattern: /pip\b.*--index-url\s+https?:\/\/(?!pypi\.org)/i,
262
+ reason: 'pip install from non-PyPI index - potential supply chain attack'
263
+ },
264
+
265
+ // MCP server manipulation
266
+ {
267
+ pattern: /\bclaude\b.*\bmcp\b.*\badd\b/i,
268
+ reason: 'Adding MCP server - verify source is trusted'
269
+ },
167
270
  ];
168
271
 
169
272
  /**
@@ -178,11 +281,70 @@ export function matchesPattern(operation: string, patterns: SecurityPattern[]):
178
281
  return null;
179
282
  }
180
283
 
284
+ /**
285
+ * Normalize file paths in Write/Edit/Read operations to resolve .. traversal.
286
+ * Prevents path traversal attacks like "Write: /home/user/../../etc/passwd"
287
+ * from matching safe home-directory patterns.
288
+ */
289
+ export function normalizeOperation(operation: string): string {
290
+ const match = operation.match(/^(Write|Edit|Read):\s*(\S+)/i);
291
+ if (match?.[2].includes('..')) {
292
+ const [, tool, rawPath] = match;
293
+ const normalizedPath = resolve(rawPath);
294
+ return `${tool}: ${normalizedPath}`;
295
+ }
296
+ return operation;
297
+ }
298
+
299
+ /** Check if a Bash command contains chain operators that could hide dangerous ops after a safe prefix. */
300
+ function containsChainOperators(operation: string): boolean {
301
+ const commandPart = operation.replace(/^Bash:\s*/i, '');
302
+ return /;|&&|\|\||\n/.test(commandPart);
303
+ }
304
+
305
+ /** Check if a Bash command pipes output to known exfiltration/network tools or shells. */
306
+ function containsDangerousPipe(operation: string): boolean {
307
+ const commandPart = operation.replace(/^Bash:\s*/i, '');
308
+ return /\|\s*(nc|netcat|ncat|curl|wget|scp|bash|sh)\b/i.test(commandPart);
309
+ }
310
+
311
+ /** Check if a Bash command redirects output to sensitive paths (append or overwrite). */
312
+ function containsSensitiveRedirect(operation: string): boolean {
313
+ const commandPart = operation.replace(/^Bash:\s*/i, '');
314
+ return />>?\s*~?\/?.*\/(authorized_keys|\.bashrc|\.bash_profile|\.zshrc|\.profile|\.ssh\/|\.aws\/|\.gnupg\/|ld\.so\.preload|crontab|sudoers)/i.test(commandPart)
315
+ || />>?\s*\/etc\//i.test(commandPart);
316
+ }
317
+
318
+ /** Check if a Bash command contains subshell or backtick expansion (not simple ${VAR}). */
319
+ function containsBashExpansion(operation: string): boolean {
320
+ const commandPart = operation.replace(/^Bash:\s*/i, '');
321
+ return /`[^`]+`/.test(commandPart) || /\$\([^)]+\)/.test(commandPart);
322
+ }
323
+
324
+ /** Check if a Bash command contains any form of shell expansion: ${VAR}, $(...), or backticks. */
325
+ function containsAnyExpansion(operation: string): boolean {
326
+ const cmd = operation.replace(/^Bash:\s*/i, '');
327
+ return /\$\{[^}]+\}/.test(cmd) || /\$\([^)]+\)/.test(cmd) || /`[^`]+`/.test(cmd);
328
+ }
329
+
330
+ /** Check if expansion is safely used as an argument to a known-safe command prefix.
331
+ * e.g., "echo ${HOME}" or "cat ${FILE}" — the expansion can't change the command itself. */
332
+ function isSafeExpansionUse(operation: string): boolean {
333
+ const cmd = operation.replace(/^Bash:\s*/i, '').trim();
334
+ // If the expansion IS the command (first token), it's never safe
335
+ if (/^(\$\{|\$\(|`)/.test(cmd)) return false;
336
+ // Safe command prefixes where expansion as an argument is harmless
337
+ const safePrefix = /^(echo|printf|cat|ls|pwd|whoami|date|env|printenv|test|true|false)\s/i;
338
+ return safePrefix.test(cmd);
339
+ }
340
+
181
341
  /**
182
342
  * Determine if operation requires AI context review
183
343
  *
184
344
  * The philosophy here is:
185
- * - SAFE_OPERATIONS: No review needed (read-only, temp files, build artifact cleanup)
345
+ * - SENSITIVE_PATHS: Always require review (credentials, system configs)
346
+ * - SAFE_OPERATIONS: No review needed, UNLESS the bash command contains
347
+ * chain operators, dangerous pipes, or subshell/backtick expansion
186
348
  * - CRITICAL_THREATS: Auto-deny, no review (catastrophic operations)
187
349
  * - Everything else: AI reviews context to determine if it matches user intent
188
350
  */
@@ -197,17 +359,48 @@ const SAFE_RM_PATTERNS = [
197
359
  ];
198
360
 
199
361
  export function requiresAIReview(operation: string): boolean {
200
- if (matchesPattern(operation, SAFE_OPERATIONS)) return false;
201
- if (matchesPattern(operation, CRITICAL_THREATS)) return false;
362
+ // Normalize paths to prevent .. traversal bypass
363
+ const op = normalizeOperation(operation);
364
+
365
+ // Check sensitive paths BEFORE safe operations — prevents home-dir
366
+ // safe pattern from masking .ssh, .aws, .bashrc, etc.
367
+ if (matchesPattern(op, SENSITIVE_PATHS)) return true;
368
+
369
+ // Bash commands with any shell expansion (${VAR}, $(...), backticks) are
370
+ // opaque — the bouncer can't predict what they expand to at runtime.
371
+ // Route to AI review BEFORE checking CRITICAL_THREATS or SAFE_OPERATIONS,
372
+ // UNLESS the command is clearly safe (expansion is just an argument to a
373
+ // known-safe prefix like "echo ${HOME}").
374
+ if (/^Bash:/i.test(op) && containsAnyExpansion(op) && !isSafeExpansionUse(op)) {
375
+ return true;
376
+ }
377
+
378
+ if (matchesPattern(op, SAFE_OPERATIONS)) {
379
+ // Safe bash commands must not contain chain operators, dangerous pipes,
380
+ // or subshell/backtick expansion that could hide dangerous operations.
381
+ // A safe prefix (e.g., "git clone") with chain operators (&&, ;, ||)
382
+ // means the full command isn't necessarily safe — route to AI review.
383
+ if (/^Bash:/i.test(op) && (
384
+ containsChainOperators(op) ||
385
+ containsDangerousPipe(op) ||
386
+ containsBashExpansion(op) ||
387
+ containsSensitiveRedirect(op)
388
+ )) {
389
+ return true;
390
+ }
391
+ return false;
392
+ }
393
+
394
+ if (matchesPattern(op, CRITICAL_THREATS)) return false;
202
395
 
203
- if (matchesPattern(operation, NEEDS_AI_REVIEW)) {
204
- return !SAFE_RM_PATTERNS.some(p => p.test(operation));
396
+ if (matchesPattern(op, NEEDS_AI_REVIEW)) {
397
+ return !SAFE_RM_PATTERNS.some(p => p.test(op));
205
398
  }
206
399
 
207
- // Variable expansion and glob patterns are only concerning in Bash commands
208
- if (/^Bash:/.test(operation)) {
209
- if (/\$\{.*\}|\$\(.*\)/.test(operation) || /\*\*?/.test(operation)) return true;
210
- if (/^Bash:\s*\.\//.test(operation)) return true;
400
+ // Glob patterns and script execution are concerning in Bash commands
401
+ if (/^Bash:/.test(op)) {
402
+ if (/\*\*?/.test(op)) return true;
403
+ if (/^Bash:\s*\.\//.test(op)) return true;
211
404
  }
212
405
 
213
406
  return false;
@@ -262,6 +455,9 @@ export function classifyRisk(operation: string): {
262
455
  { pattern: /chmod\s+777/i, reason: 'Dangerous permissions' },
263
456
  { pattern: /(curl|wget).*\|.*(bash|sh)/i, reason: 'Remote code execution' },
264
457
  { pattern: /pkill|killall/i, reason: 'Process termination' },
458
+ { pattern: /\|\s*(nc|netcat|ncat)\b/i, reason: 'Data exfiltration via netcat' },
459
+ { pattern: /\bscp\b.*@/i, reason: 'Data exfiltration via SCP' },
460
+ { pattern: /curl\b.*-d\s*@/i, reason: 'Data exfiltration via curl file upload' },
265
461
  ];
266
462
 
267
463
  for (const pattern of elevatedPatterns) {