@intutic/mcp-governance-proxy 1.0.3

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-test.log +51 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/LICENSE +21 -0
  5. package/README.md +87 -0
  6. package/dist/config.d.ts +40 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +100 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/daemon/healthMonitor.d.ts +18 -0
  11. package/dist/daemon/healthMonitor.d.ts.map +1 -0
  12. package/dist/daemon/healthMonitor.js +185 -0
  13. package/dist/daemon/healthMonitor.js.map +1 -0
  14. package/dist/daemon/index.d.ts +2 -0
  15. package/dist/daemon/index.d.ts.map +1 -0
  16. package/dist/daemon/index.js +77 -0
  17. package/dist/daemon/index.js.map +1 -0
  18. package/dist/daemon/policyCache.d.ts +20 -0
  19. package/dist/daemon/policyCache.d.ts.map +1 -0
  20. package/dist/daemon/policyCache.js +124 -0
  21. package/dist/daemon/policyCache.js.map +1 -0
  22. package/dist/daemon/socketServer.d.ts +13 -0
  23. package/dist/daemon/socketServer.d.ts.map +1 -0
  24. package/dist/daemon/socketServer.js +90 -0
  25. package/dist/daemon/socketServer.js.map +1 -0
  26. package/dist/daemon/telemetryBatcher.d.ts +15 -0
  27. package/dist/daemon/telemetryBatcher.d.ts.map +1 -0
  28. package/dist/daemon/telemetryBatcher.js +143 -0
  29. package/dist/daemon/telemetryBatcher.js.map +1 -0
  30. package/dist/daemonClient.d.ts +5 -0
  31. package/dist/daemonClient.d.ts.map +1 -0
  32. package/dist/daemonClient.js +67 -0
  33. package/dist/daemonClient.js.map +1 -0
  34. package/dist/dlp.d.ts +29 -0
  35. package/dist/dlp.d.ts.map +1 -0
  36. package/dist/dlp.js +58 -0
  37. package/dist/dlp.js.map +1 -0
  38. package/dist/emitter.d.ts +34 -0
  39. package/dist/emitter.d.ts.map +1 -0
  40. package/dist/emitter.js +121 -0
  41. package/dist/emitter.js.map +1 -0
  42. package/dist/index.d.ts +24 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +50 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/interceptor.d.ts +38 -0
  47. package/dist/interceptor.d.ts.map +1 -0
  48. package/dist/interceptor.js +93 -0
  49. package/dist/interceptor.js.map +1 -0
  50. package/dist/policy.d.ts +41 -0
  51. package/dist/policy.d.ts.map +1 -0
  52. package/dist/policy.js +130 -0
  53. package/dist/policy.js.map +1 -0
  54. package/dist/proxy.d.ts +43 -0
  55. package/dist/proxy.d.ts.map +1 -0
  56. package/dist/proxy.js +238 -0
  57. package/dist/proxy.js.map +1 -0
  58. package/dist/stderrLog.d.ts +32 -0
  59. package/dist/stderrLog.d.ts.map +1 -0
  60. package/dist/stderrLog.js +45 -0
  61. package/dist/stderrLog.js.map +1 -0
  62. package/dummy-path.jsonl +2 -0
  63. package/package.json +32 -0
  64. package/src/__tests__/config.test.ts +82 -0
  65. package/src/__tests__/dlp.test.ts +103 -0
  66. package/src/__tests__/integration/daemon-shim.test.ts +125 -0
  67. package/src/__tests__/interceptor.test.ts +229 -0
  68. package/src/__tests__/unit/policyCache.test.ts +95 -0
  69. package/src/__tests__/unit/socketServer.test.ts +220 -0
  70. package/src/__tests__/unit/telemetryBatcher.test.ts +78 -0
  71. package/src/config.ts +145 -0
  72. package/src/daemon/healthMonitor.ts +210 -0
  73. package/src/daemon/index.ts +76 -0
  74. package/src/daemon/policyCache.ts +143 -0
  75. package/src/daemon/socketServer.ts +111 -0
  76. package/src/daemon/telemetryBatcher.ts +147 -0
  77. package/src/daemonClient.ts +71 -0
  78. package/src/dlp.ts +72 -0
  79. package/src/emitter.ts +144 -0
  80. package/src/index.ts +53 -0
  81. package/src/interceptor.ts +103 -0
  82. package/src/policy.ts +152 -0
  83. package/src/proxy.ts +327 -0
  84. package/src/stderrLog.ts +56 -0
  85. package/tsconfig.json +10 -0
  86. package/tsconfig.tsbuildinfo +1 -0
  87. package/vitest.config.ts +9 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * JSON-RPC over Unix Domain Socket server
3
+ *
4
+ * Accepts newline-delimited JSON requests from MCP shims.
5
+ * Methods: proxy.tool_call, proxy.health_check, policy.get, telemetry.flush
6
+ *
7
+ * LLD #28: MCP Daemon Mode, WS-5MCP
8
+ * @module
9
+ */
10
+ import net from 'node:net'
11
+ import path from 'node:path'
12
+ import os from 'node:os'
13
+ import { createLogger } from '@intutic/logger'
14
+ import { resolvePolicy, getCacheStats, invalidatePolicy } from './policyCache.js'
15
+ import { enqueueEvent } from './telemetryBatcher.js'
16
+ import { getHealthSnapshot } from './healthMonitor.js'
17
+ import type { HookEvent } from './telemetryBatcher.js'
18
+
19
+ const logger = createLogger('mcp-proxy.socketServer')
20
+
21
+ export function getSocketPath(): string {
22
+ return process.env['MCP_DAEMON_SOCKET'] ??
23
+ path.join(os.homedir(), '.intutic', 'mcp-proxy.sock')
24
+ }
25
+
26
+ interface JsonRpcRequest {
27
+ jsonrpc: '2.0'
28
+ id: string | number
29
+ method: string
30
+ params: Record<string, unknown>
31
+ }
32
+
33
+ function respond(socket: net.Socket, id: string | number, result: unknown): void {
34
+ socket.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
35
+ }
36
+
37
+ function respondError(socket: net.Socket, id: string | number, code: number, message: string): void {
38
+ socket.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n')
39
+ }
40
+
41
+ async function handleRequest(socket: net.Socket, req: JsonRpcRequest): Promise<void> {
42
+ const { id, method, params } = req
43
+
44
+ switch (method) {
45
+ case 'proxy.health_check':
46
+ respond(socket, id, { status: 'ok', version: process.env['npm_package_version'] ?? '0.0.0',
47
+ cacheStats: getCacheStats(), mcpServers: getHealthSnapshot() })
48
+ break
49
+
50
+ case 'proxy.tool_call': {
51
+ const workspaceId = params['workspaceId'] as string
52
+ const policy = await resolvePolicy(workspaceId)
53
+ // Simplified enforcement: if policy loaded, apply it
54
+ const allowed = policy !== null
55
+ respond(socket, id, { allowed, policy: policy ?? null })
56
+ break
57
+ }
58
+
59
+ case 'policy.get': {
60
+ const workspaceId = params['workspaceId'] as string
61
+ const policy = await resolvePolicy(workspaceId)
62
+ respond(socket, id, policy)
63
+ break
64
+ }
65
+
66
+ case 'policy.invalidate': {
67
+ const workspaceId = params['workspaceId'] as string
68
+ invalidatePolicy(workspaceId)
69
+ respond(socket, id, { invalidated: true })
70
+ break
71
+ }
72
+
73
+ case 'telemetry.enqueue': {
74
+ enqueueEvent(params as unknown as HookEvent)
75
+ respond(socket, id, { queued: true })
76
+ break
77
+ }
78
+
79
+ default:
80
+ respondError(socket, id, -32601, `Method not found: ${method}`)
81
+ }
82
+ }
83
+
84
+ export function createSocketServer(): net.Server {
85
+ const server = net.createServer((socket) => {
86
+ socket.setEncoding('utf8')
87
+ let buffer = ''
88
+
89
+ socket.on('data', (chunk: string) => {
90
+ buffer += chunk
91
+ const lines = buffer.split('\n')
92
+ buffer = lines.pop() ?? ''
93
+ for (const line of lines) {
94
+ const trimmed = line.trim()
95
+ if (!trimmed) continue
96
+ try {
97
+ const req = JSON.parse(trimmed) as JsonRpcRequest
98
+ void handleRequest(socket, req).catch(err =>
99
+ respondError(socket, req.id, -32603, String(err)))
100
+ } catch {
101
+ socket.write(JSON.stringify({ jsonrpc: '2.0', id: null,
102
+ error: { code: -32700, message: 'Parse error' } }) + '\n')
103
+ }
104
+ }
105
+ })
106
+
107
+ socket.on('error', (err) => logger.warn({ err }, 'socket.client_error'))
108
+ })
109
+
110
+ return server
111
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Telemetry Batcher — ring buffer + 1s flush to control-plane
3
+ *
4
+ * Accumulates hook events and batches them for upload.
5
+ * Fallback: Valkey event buffer, followed by disk buffer at ~/.intutic/telemetry-buffer.ndjson on flush failure.
6
+ *
7
+ * LLD #28: MCP Daemon Mode, WS-5MCP
8
+ * @module
9
+ */
10
+ import https from 'node:https'
11
+ import http from 'node:http'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { Redis } from 'ioredis'
16
+ import { createLogger } from '@intutic/logger'
17
+
18
+ const logger = createLogger('mcp-proxy.telemetryBatcher')
19
+
20
+ const CP_URL = process.env['CONTROL_PLANE_URL'] ?? 'http://localhost:3001'
21
+ const DAEMON_API_KEY = process.env['INTUTIC_API_KEY'] ?? ''
22
+ const FLUSH_MS = parseInt(process.env['MCP_DAEMON_TELEMETRY_FLUSH_MS'] ?? '1000', 10)
23
+ const RING_CAPACITY = 200
24
+ const DISK_BUFFER = path.join(os.homedir(), '.intutic', 'telemetry-buffer.ndjson')
25
+
26
+ const VALKEY_URL = process.env['VALKEY_URL'] ?? process.env['REDIS_URL'] ?? 'redis://localhost:6379'
27
+ const valkey = new Redis(VALKEY_URL, {
28
+ lazyConnect: true,
29
+ maxRetriesPerRequest: 3,
30
+ })
31
+
32
+ valkey.on('error', (err: any) => {
33
+ logger.warn({ err: err.message }, 'telemetryBatcher Valkey connection error')
34
+ })
35
+
36
+ const daemonId = process.pid.toString()
37
+
38
+ export interface HookEvent {
39
+ event: string
40
+ toolName: string
41
+ workspaceId: string
42
+ harnessType: string
43
+ timestamp: string
44
+ [key: string]: unknown
45
+ }
46
+
47
+ const ring: HookEvent[] = []
48
+ let flushTimer: ReturnType<typeof setInterval> | null = null
49
+ let isFlushing = false
50
+
51
+ /** Enqueues a hook event. Evicts oldest on overflow. */
52
+ export function enqueueEvent(event: HookEvent): void {
53
+ if (ring.length >= RING_CAPACITY) ring.shift()
54
+ ring.push(event)
55
+ }
56
+
57
+ async function flush(): Promise<void> {
58
+ if (isFlushing || ring.length === 0) return
59
+ isFlushing = true
60
+ const batch = ring.splice(0, ring.length)
61
+
62
+ const body = JSON.stringify({ events: batch })
63
+ const url = new URL('/api/v1/hook-events', CP_URL)
64
+ const isHttps = url.protocol === 'https:'
65
+ const lib = isHttps ? https : http
66
+
67
+ const tryUpload = (attempt: number): Promise<void> => new Promise((resolve, reject) => {
68
+ const req = lib.request(
69
+ { hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
70
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body),
71
+ 'Authorization': `Bearer ${DAEMON_API_KEY}` } },
72
+ (res) => {
73
+ res.resume()
74
+ if (res.statusCode && res.statusCode < 400) resolve()
75
+ else reject(new Error(`HTTP ${res.statusCode}`))
76
+ }
77
+ )
78
+ req.on('error', reject)
79
+ req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')) })
80
+ req.write(body)
81
+ req.end()
82
+ })
83
+
84
+ let success = false
85
+ for (let i = 0; i < 3; i++) {
86
+ try { await tryUpload(i); success = true; break }
87
+ catch { await new Promise(r => setTimeout(r, 500 * (i + 1))) }
88
+ }
89
+
90
+ if (!success) {
91
+ // Persist to Valkey first
92
+ try {
93
+ const key = `mcp_daemon:event_buffer:${daemonId}`
94
+ await valkey.rpush(key, ...batch.map(e => JSON.stringify(e)))
95
+ await valkey.expire(key, 600) // 10 min TTL
96
+ logger.warn({ count: batch.length }, 'telemetry.flush_failed_buffered_to_valkey')
97
+ } catch (valkeyErr: any) {
98
+ // Fallback: local disk buffer
99
+ try {
100
+ fs.mkdirSync(path.dirname(DISK_BUFFER), { recursive: true, mode: 0o700 })
101
+ fs.appendFileSync(DISK_BUFFER, batch.map(e => JSON.stringify(e)).join('\n') + '\n', { mode: 0o600 })
102
+ logger.warn({ count: batch.length }, 'telemetry.flush_failed_buffered_to_disk')
103
+ } catch (diskErr) {
104
+ logger.error({ diskErr, count: batch.length }, 'telemetry.disk_buffer_failed')
105
+ }
106
+ }
107
+ } else {
108
+ logger.debug({ count: batch.length }, 'telemetry.flushed')
109
+ }
110
+ isFlushing = false
111
+ }
112
+
113
+ /** Drains any Valkey or disk buffer from a previous crash, then starts the flush timer. */
114
+ export function startBatcher(): void {
115
+ // Drain Valkey buffer
116
+ const key = `mcp_daemon:event_buffer:${daemonId}`
117
+ valkey.lrange(key, 0, -1).then(async (lines: string[]) => {
118
+ if (lines && lines.length > 0) {
119
+ for (const line of lines) {
120
+ try { enqueueEvent(JSON.parse(line) as HookEvent) } catch {}
121
+ }
122
+ await valkey.del(key)
123
+ logger.info({ count: lines.length }, 'telemetry.valkey_buffer_drained')
124
+ }
125
+ }).catch(() => {})
126
+
127
+ // Drain disk buffer
128
+ try {
129
+ if (fs.existsSync(DISK_BUFFER)) {
130
+ const lines = fs.readFileSync(DISK_BUFFER, 'utf8').trim().split('\n').filter(Boolean)
131
+ for (const line of lines) {
132
+ try { enqueueEvent(JSON.parse(line) as HookEvent) } catch {}
133
+ }
134
+ fs.unlinkSync(DISK_BUFFER)
135
+ logger.info({ count: lines.length }, 'telemetry.disk_buffer_drained')
136
+ }
137
+ } catch {}
138
+
139
+ flushTimer = setInterval(() => { void flush() }, FLUSH_MS)
140
+ flushTimer.unref()
141
+ }
142
+
143
+ /** Flushes remaining events and stops the timer. */
144
+ export async function stopBatcher(): Promise<void> {
145
+ if (flushTimer) { clearInterval(flushTimer); flushTimer = null }
146
+ await flush()
147
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * daemonClient.ts — IPC socket client helper for communicating with mcp-daemon.
3
+ * LLD #28: MCP Daemon Mode, WS-5MCP
4
+ *
5
+ * @module
6
+ */
7
+ import * as net from 'node:net'
8
+ import * as path from 'node:path'
9
+ import * as os from 'node:os'
10
+ import { createStderrLogger as createLogger } from './stderrLog.js'
11
+
12
+ const log = createLogger('mcp-proxy-daemon-client')
13
+
14
+ function getSocketPath(): string {
15
+ return process.env['MCP_DAEMON_SOCKET'] ??
16
+ path.join(os.homedir(), '.intutic', 'mcp-proxy.sock')
17
+ }
18
+
19
+ /**
20
+ * Sends a JSON-RPC request to the mcp-daemon Unix domain socket.
21
+ */
22
+ export function callDaemonSocket(method: string, params: Record<string, unknown>): Promise<any> {
23
+ return new Promise((resolve, reject) => {
24
+ const socket = net.createConnection(getSocketPath())
25
+ socket.setEncoding('utf8')
26
+
27
+ let buffer = ''
28
+ const req = {
29
+ jsonrpc: '2.0',
30
+ id: Date.now().toString() + Math.random().toString(36).substring(2, 7),
31
+ method,
32
+ params,
33
+ }
34
+
35
+ socket.on('connect', () => {
36
+ socket.write(JSON.stringify(req) + '\n')
37
+ })
38
+
39
+ socket.on('data', (chunk: string) => {
40
+ buffer += chunk
41
+ const lines = buffer.split('\n')
42
+ if (lines.length > 1) {
43
+ const line = lines[0]!.trim()
44
+ if (line) {
45
+ try {
46
+ const res = JSON.parse(line)
47
+ if (res.error) {
48
+ reject(new Error(res.error.message))
49
+ } else {
50
+ resolve(res.result)
51
+ }
52
+ } catch (err) {
53
+ reject(err)
54
+ } finally {
55
+ socket.end()
56
+ }
57
+ }
58
+ }
59
+ })
60
+
61
+ socket.on('error', (err) => {
62
+ reject(err)
63
+ })
64
+
65
+ // 2s timeout
66
+ socket.setTimeout(2000, () => {
67
+ socket.destroy()
68
+ reject(new Error('Daemon socket timeout'))
69
+ })
70
+ })
71
+ }
package/src/dlp.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * dlp.ts — DLP (Data Loss Prevention) argument scanner.
3
+ *
4
+ * Scans tool arguments (as a JSON string) for patterns that indicate
5
+ * credentials, secrets, PII, or other sensitive data before allowing
6
+ * the tool call to proceed.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ export interface DlpFinding {
12
+ pattern: string
13
+ description: string
14
+ }
15
+
16
+ export interface DlpScanResult {
17
+ hasFinding: boolean
18
+ findings: DlpFinding[]
19
+ }
20
+
21
+ /**
22
+ * Compiled DLP patterns. Each entry has a regex and a human-readable description.
23
+ */
24
+ const DLP_PATTERNS: Array<{ regex: RegExp; description: string }> = [
25
+ // API keys / tokens
26
+ { regex: /sk-[A-Za-z0-9]{20,}/, description: 'OpenAI API key pattern' },
27
+ { regex: /sk-ant-[A-Za-z0-9\-_]{20,}/, description: 'Anthropic API key pattern' },
28
+ { regex: /AIza[A-Za-z0-9\-_]{35}/, description: 'Google API key pattern' },
29
+ { regex: /ghp_[A-Za-z0-9]{36}/, description: 'GitHub personal access token' },
30
+ { regex: /ghs_[A-Za-z0-9]{36}/, description: 'GitHub server token' },
31
+ { regex: /xoxb-[A-Za-z0-9\-]{50,}/, description: 'Slack bot token' },
32
+ { regex: /xoxp-[A-Za-z0-9\-]{50,}/, description: 'Slack user token' },
33
+ { regex: /AKIA[A-Z0-9]{16}/, description: 'AWS Access Key ID' },
34
+ // High-entropy strings that look like secrets (≥40 chars of hex or base64)
35
+ { regex: /[0-9a-f]{40,}/, description: 'High-entropy hex string (possible secret)' },
36
+ // Destructive shell patterns in Bash/command arguments
37
+ { regex: /rm\s+-rf?\s+\//, description: 'Destructive rm -rf / command' },
38
+ { regex: /DROP\s+TABLE/i, description: 'SQL DROP TABLE statement' },
39
+ { regex: /DROP\s+DATABASE/i, description: 'SQL DROP DATABASE statement' },
40
+ { regex: /TRUNCATE\s+TABLE/i, description: 'SQL TRUNCATE TABLE statement' },
41
+ // Private key material
42
+ { regex: /-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----/, description: 'PEM private key material' },
43
+ { regex: /-----BEGIN\s+EC\s+PRIVATE KEY-----/, description: 'EC private key material' },
44
+ ]
45
+
46
+ /**
47
+ * Scan tool arguments for DLP findings.
48
+ *
49
+ * @param toolInput - The tool_input object from the MCP tools/call request
50
+ * @returns DLP scan result with any findings
51
+ */
52
+ export function scanToolInput(toolInput: unknown): DlpScanResult {
53
+ // Serialize to string for pattern matching
54
+ const serialized = JSON.stringify(toolInput ?? {})
55
+ const findings: DlpFinding[] = []
56
+
57
+ for (const { regex, description } of DLP_PATTERNS) {
58
+ if (regex.test(serialized)) {
59
+ findings.push({ pattern: regex.source, description })
60
+ }
61
+ }
62
+
63
+ return { hasFinding: findings.length > 0, findings }
64
+ }
65
+
66
+ /**
67
+ * Format DLP findings into a human-readable block reason.
68
+ */
69
+ export function formatDlpBlockReason(findings: DlpFinding[]): string {
70
+ const list = findings.map((f) => `• ${f.description}`).join('\n')
71
+ return `Tool call blocked by Intutic DLP scanner. Sensitive data detected:\n${list}`
72
+ }
package/src/emitter.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * emitter.ts — Dual-path governance event emitter.
3
+ *
4
+ * Path A: HTTP POST to /api/v1/hook-events (same endpoint as claudeCodeHooks.ts)
5
+ * Path B: Append JSONL line to ~/.intutic/events/hook-events.jsonl
6
+ *
7
+ * Mirrors the dual-path pattern from claudeCodeHooks.ts / syncLoop.ts.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import * as node_fs from 'node:fs/promises'
13
+ import * as node_https from 'node:https'
14
+ import * as node_http from 'node:http'
15
+ import * as node_path from 'node:path'
16
+ import * as node_crypto from 'node:crypto'
17
+ import { createStderrLogger as createLogger } from './stderrLog.js'
18
+ import { callDaemonSocket } from './daemonClient.js'
19
+
20
+ const log = createLogger('mcp-proxy-emitter')
21
+
22
+ export type EventKind = 'tool_allowed' | 'tool_blocked' | 'tool_redacted'
23
+
24
+ export interface GovernanceEvent {
25
+ incidentId: string
26
+ kind: EventKind
27
+ toolName: string
28
+ toolInput: unknown
29
+ workspaceId: string
30
+ harnessType: string
31
+ reason?: string
32
+ timestamp: string
33
+ }
34
+
35
+ export class GovernanceEmitter {
36
+ constructor(
37
+ private readonly controlPlaneUrl: string,
38
+ private readonly apiKey: string,
39
+ private readonly eventsFilePath: string,
40
+ private readonly workspaceId: string,
41
+ private readonly mcpProxyMode: string = 'per-session'
42
+ ) {}
43
+
44
+ emit(kind: EventKind, toolName: string, toolInput: unknown, reason?: string): void {
45
+ const event: GovernanceEvent = {
46
+ incidentId: node_crypto.randomUUID(),
47
+ kind,
48
+ toolName,
49
+ toolInput,
50
+ workspaceId: this.workspaceId,
51
+ harnessType: 'mcp-governance-proxy',
52
+ reason,
53
+ timestamp: new Date().toISOString(),
54
+ }
55
+
56
+ if (this.mcpProxyMode === 'daemon') {
57
+ const eventPayload = {
58
+ event: kind === 'tool_blocked' ? 'tool_blocked' : 'tool_allowed',
59
+ toolName,
60
+ workspaceId: this.workspaceId,
61
+ harnessType: 'mcp-governance-proxy',
62
+ timestamp: event.timestamp,
63
+ reason,
64
+ toolInput,
65
+ }
66
+ callDaemonSocket('telemetry.enqueue', eventPayload).then(() => {
67
+ log.debug({ action: 'telemetry_enqueued' }, 'Telemetry successfully enqueued to daemon')
68
+ }).catch((err) => {
69
+ log.warn({ action: 'telemetry_daemon_failed', err: err.message }, 'Failed to enqueue telemetry to daemon socket — falling back to dual-path')
70
+ this.runDualPath(event)
71
+ })
72
+ return
73
+ }
74
+
75
+ this.runDualPath(event)
76
+ }
77
+
78
+ private runDualPath(event: GovernanceEvent): void {
79
+ // Path A: HTTP POST (best effort)
80
+ this.postToControlPlane(event).catch((err) => {
81
+ log.warn({ action: 'emit_path_a_failed', err: (err as Error).message }, 'Path A emission failed')
82
+ })
83
+
84
+ // Path B: JSONL file append (best effort)
85
+ this.appendToFile(event).catch((err) => {
86
+ log.warn({ action: 'emit_path_b_failed', err: (err as Error).message }, 'Path B emission failed')
87
+ })
88
+ }
89
+
90
+ private async postToControlPlane(event: GovernanceEvent): Promise<void> {
91
+ const payload = JSON.stringify({
92
+ events: [
93
+ {
94
+ event: event.kind === 'tool_blocked' ? 'tool_blocked' : 'tool_allowed',
95
+ toolName: event.toolName,
96
+ toolInput: event.toolInput,
97
+ workspaceId: event.workspaceId,
98
+ harnessType: event.harnessType,
99
+ incidentId: event.incidentId,
100
+ reason: event.reason,
101
+ timestamp: event.timestamp,
102
+ },
103
+ ],
104
+ })
105
+
106
+ const url = `${this.controlPlaneUrl}/api/v1/telemetry/enqueue`
107
+ await httpPost(url, this.apiKey, payload)
108
+ }
109
+
110
+ private async appendToFile(event: GovernanceEvent): Promise<void> {
111
+ const dir = node_path.dirname(this.eventsFilePath)
112
+ await node_fs.mkdir(dir, { recursive: true })
113
+ const line = JSON.stringify(event) + '\n'
114
+ await node_fs.appendFile(this.eventsFilePath, line, 'utf-8')
115
+ }
116
+ }
117
+
118
+ function httpPost(url: string, apiKey: string, body: string): Promise<void> {
119
+ return new Promise((resolve, reject) => {
120
+ const parsed = new URL(url)
121
+ const lib = parsed.protocol === 'https:' ? node_https : node_http
122
+ const req = lib.request(
123
+ url,
124
+ {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ 'Content-Length': Buffer.byteLength(body),
129
+ Authorization: `Bearer ${apiKey}`,
130
+ },
131
+ timeout: 4000,
132
+ },
133
+ (res) => {
134
+ // Drain response body to free socket
135
+ res.resume()
136
+ res.on('end', resolve)
137
+ }
138
+ )
139
+ req.on('error', reject)
140
+ req.on('timeout', () => { req.destroy(); reject(new Error('HTTP POST timed out')) })
141
+ req.write(body)
142
+ req.end()
143
+ })
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * index.ts — CLI entry point for @intutic/mcp-governance-proxy.
4
+ *
5
+ * CRITICAL: In the MCP proxy, stdout is exclusively reserved for JSON-RPC frames.
6
+ * All logging MUST go to stderr. We redirect pino's output to fd 2 (stderr)
7
+ * by overriding the destination before any logger is instantiated.
8
+ *
9
+ * Usage:
10
+ * intutic-mcp-proxy [--workspace-id <id>] -- <real-mcp-server> [args...]
11
+ *
12
+ * Example (harness config):
13
+ * {
14
+ * "command": "node",
15
+ * "args": ["/path/to/@intutic/mcp-governance-proxy/dist/index.js",
16
+ * "--workspace-id", "ws_xxx",
17
+ * "--",
18
+ * "npx", "-y", "@modelcontextprotocol/server-filesystem", "/projects"]
19
+ * }
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { loadConfig } from './config.js'
25
+ import { McpGovernanceProxy } from './proxy.js'
26
+ import { createStderrLogger as createLogger } from './stderrLog.js'
27
+
28
+ const log = createLogger('mcp-proxy-main')
29
+
30
+ async function main(): Promise<void> {
31
+ let config
32
+ try {
33
+ config = await loadConfig()
34
+ } catch (err) {
35
+ // Write to stderr — never stdout
36
+ process.stderr.write(`[intutic-mcp-proxy] Configuration error: ${(err as Error).message}\n`)
37
+ process.exit(1)
38
+ }
39
+
40
+ const proxy = new McpGovernanceProxy(config)
41
+
42
+ try {
43
+ await proxy.run()
44
+ } catch (err) {
45
+ log.error({ action: 'proxy_fatal', err: (err as Error).message }, 'Proxy exited with error')
46
+ process.exit(1)
47
+ }
48
+ }
49
+
50
+ main().catch((err: unknown) => {
51
+ process.stderr.write(`[intutic-mcp-proxy] Fatal error: ${String(err)}\n`)
52
+ process.exit(1)
53
+ })