@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,78 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2
+ import * as http from 'node:http'
3
+ import * as net from 'node:net'
4
+ import * as os from 'node:os'
5
+ import * as path from 'node:path'
6
+ import * as fs from 'node:fs/promises'
7
+
8
+ describe('telemetryBatcher Unit Tests', () => {
9
+ let mockServer: http.Server
10
+ let port: number
11
+ let receivedPayloads: any[] = []
12
+
13
+ let enqueueEvent: any
14
+ let startBatcher: any
15
+ let stopBatcher: any
16
+
17
+ beforeAll(async () => {
18
+ // Clear stale disk buffer file to ensure hermetic tests
19
+ const bufPath = path.join(os.homedir(), '.intutic', 'telemetry-buffer.ndjson')
20
+ await fs.rm(bufPath, { force: true })
21
+
22
+ mockServer = http.createServer((req, res) => {
23
+ let body = ''
24
+ req.on('data', (c) => {
25
+ body += c.toString()
26
+ })
27
+ req.on('end', () => {
28
+ try {
29
+ receivedPayloads.push(JSON.parse(body))
30
+ } catch {}
31
+ res.writeHead(200, { 'Content-Type': 'application/json' })
32
+ res.end(JSON.stringify({ ok: true }))
33
+ })
34
+ })
35
+
36
+ await new Promise<void>((resolve) => {
37
+ mockServer.listen(0, '127.0.0.1', () => {
38
+ const addr = mockServer.address() as net.AddressInfo | null
39
+ port = addr?.port ?? 3001
40
+ process.env['CONTROL_PLANE_URL'] = `http://127.0.0.1:${port}`
41
+ process.env['MCP_DAEMON_TELEMETRY_FLUSH_MS'] = '100' // Flush fast for test
42
+ resolve()
43
+ })
44
+ })
45
+
46
+ // Import after process.env is set to ensure it uses the mock URL
47
+ const mod = await import('../../daemon/telemetryBatcher.js')
48
+ enqueueEvent = mod.enqueueEvent
49
+ startBatcher = mod.startBatcher
50
+ stopBatcher = mod.stopBatcher
51
+ })
52
+
53
+ afterAll(async () => {
54
+ await stopBatcher()
55
+ await new Promise<void>((resolve) => mockServer.close(() => resolve()))
56
+ })
57
+
58
+ it('batches and uploads enqueued telemetry events', async () => {
59
+ startBatcher()
60
+
61
+ const event = {
62
+ event: 'tool_blocked',
63
+ toolName: 'Bash',
64
+ workspaceId: 'ws_test_telemetry',
65
+ harnessType: 'mcp-governance-proxy',
66
+ timestamp: new Date().toISOString(),
67
+ }
68
+
69
+ enqueueEvent(event)
70
+
71
+ // Wait for the 100ms flush timer to trigger
72
+ await new Promise((resolve) => setTimeout(resolve, 250))
73
+
74
+ expect(receivedPayloads).toHaveLength(1)
75
+ expect(receivedPayloads[0].events).toHaveLength(1)
76
+ expect(receivedPayloads[0].events[0].toolName).toBe('Bash')
77
+ })
78
+ })
package/src/config.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * config.ts — MCP Governance Proxy configuration.
3
+ *
4
+ * Reads from ~/.intutic/env/runtime.env (written by sync-daemon runtimeEnv.ts)
5
+ * and from CLI arguments passed after `--`.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import * as node_fs from 'node:fs/promises'
11
+ import * as node_os from 'node:os'
12
+ import * as node_path from 'node:path'
13
+
14
+ export interface ProxyConfig {
15
+ /** Workspace ID for policy lookups and event attribution */
16
+ workspaceId: string
17
+ /** Control plane base URL (e.g. http://localhost:3001) */
18
+ controlPlaneUrl: string
19
+ /** API key for control plane requests */
20
+ apiKey: string
21
+ /** Real MCP server command + args (everything after --). Empty = standalone mode. */
22
+ realServerCommand: string[]
23
+ /** Path to write hook-events JSONL (Path B) */
24
+ eventsFilePath: string
25
+ /** Policy cache TTL in milliseconds (default: 60_000) */
26
+ policyTtlMs: number
27
+ /** Whether to fail-open when control plane is unreachable (default: true) */
28
+ failOpen: boolean
29
+ /**
30
+ * MCP proxy deployment model ('per-session' | 'daemon').
31
+ * WS-5 Q2 — stored and propagated but 'daemon' is not yet active in Phase 4.
32
+ */
33
+ mcpProxyMode: string
34
+ /**
35
+ * Standalone mode — when true, the proxy acts as the Intutic MCP server
36
+ * directly (no downstream server to proxy). Used for the `intutic` harness
37
+ * entry that exposes Intutic governance tools to the harness.
38
+ */
39
+ standalone: boolean
40
+ }
41
+
42
+ const DEFAULT_EVENTS_PATH = node_path.join(node_os.homedir(), '.intutic', 'events', 'hook-events.jsonl')
43
+ const RUNTIME_ENV_PATH = node_path.join(node_os.homedir(), '.intutic', 'env', 'runtime.env')
44
+
45
+ /**
46
+ * Parse a .env-format file into a key→value record.
47
+ * Lines starting with # are comments; blank lines are skipped.
48
+ */
49
+ async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
50
+ const result: Record<string, string> = {}
51
+ try {
52
+ const content = await node_fs.readFile(filePath, 'utf-8')
53
+ for (const line of content.split('\n')) {
54
+ const trimmed = line.trim()
55
+ if (!trimmed || trimmed.startsWith('#')) continue
56
+ const eq = trimmed.indexOf('=')
57
+ if (eq === -1) continue
58
+ const key = trimmed.slice(0, eq).trim()
59
+ // Strip surrounding quotes from value
60
+ let value = trimmed.slice(eq + 1).trim()
61
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
62
+ value = value.slice(1, -1)
63
+ }
64
+ result[key] = value
65
+ }
66
+ } catch {
67
+ // File may not exist — return empty record
68
+ }
69
+ return result
70
+ }
71
+
72
+ /**
73
+ * Parse CLI arguments for --workspace-id and the real server command (after --).
74
+ */
75
+ function parseCliArgs(argv: string[]): { workspaceId?: string; realServerCommand: string[] } {
76
+ let workspaceId: string | undefined
77
+ let separatorIndex = -1
78
+
79
+ for (let i = 0; i < argv.length; i++) {
80
+ if (argv[i] === '--workspace-id' && i + 1 < argv.length) {
81
+ workspaceId = argv[i + 1]
82
+ i++ // skip value
83
+ } else if (argv[i] === '--') {
84
+ separatorIndex = i
85
+ break
86
+ }
87
+ }
88
+
89
+ const realServerCommand = separatorIndex !== -1 ? argv.slice(separatorIndex + 1) : []
90
+ return { workspaceId, realServerCommand }
91
+ }
92
+
93
+ /**
94
+ * Load the proxy configuration from runtime.env + CLI args + environment.
95
+ */
96
+ export async function loadConfig(argv: string[] = process.argv.slice(2)): Promise<ProxyConfig> {
97
+ const runtimeEnv = await parseEnvFile(RUNTIME_ENV_PATH)
98
+ const cli = parseCliArgs(argv)
99
+
100
+ const controlPlaneUrl =
101
+ process.env['INTUTIC_CONTROL_PLANE_URL'] ??
102
+ runtimeEnv['INTUTIC_CONTROL_PLANE_URL'] ??
103
+ 'http://localhost:3001'
104
+
105
+ const apiKey =
106
+ process.env['INTUTIC_API_KEY'] ??
107
+ runtimeEnv['INTUTIC_API_KEY'] ??
108
+ ''
109
+
110
+ const workspaceId =
111
+ cli.workspaceId ??
112
+ process.env['INTUTIC_WORKSPACE_ID'] ??
113
+ runtimeEnv['INTUTIC_WORKSPACE_ID'] ??
114
+ 'unknown'
115
+
116
+ const eventsFilePath =
117
+ process.env['INTUTIC_EVENTS_FILE'] ??
118
+ runtimeEnv['INTUTIC_EVENTS_FILE'] ??
119
+ DEFAULT_EVENTS_PATH
120
+
121
+ const failOpen =
122
+ (runtimeEnv['INTUTIC_MCP_FAIL_OPEN'] ?? 'true').toLowerCase() !== 'false'
123
+
124
+ const mcpProxyMode = runtimeEnv['INTUTIC_MCP_PROXY_MODE'] ?? 'per-session'
125
+ if (mcpProxyMode === 'daemon') {
126
+ console.warn(
127
+ '[mcp-proxy] INTUTIC_MCP_PROXY_MODE=daemon is stored but not yet active — '
128
+ + 'running in per-session mode (Phase 4). Will be activated in Phase 5.'
129
+ )
130
+ }
131
+
132
+ const standalone = cli.realServerCommand.length === 0
133
+
134
+ return {
135
+ workspaceId,
136
+ controlPlaneUrl,
137
+ apiKey,
138
+ realServerCommand: cli.realServerCommand,
139
+ eventsFilePath,
140
+ policyTtlMs: 60_000,
141
+ failOpen,
142
+ mcpProxyMode,
143
+ standalone,
144
+ }
145
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * MCP Server Health Monitor
3
+ *
4
+ * Probes MCP servers every 30s and records health snapshots.
5
+ * Emits mcp_daemon.mcp_server_down when a server becomes unreachable.
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 { execSync } from 'node:child_process'
16
+ import { createLogger } from '@intutic/logger'
17
+
18
+ const logger = createLogger('mcp-proxy.healthMonitor')
19
+
20
+ const HEARTBEAT_MS = 30_000
21
+ const PROBE_TIMEOUT = 5_000
22
+
23
+ const CP_URL = process.env['CONTROL_PLANE_URL'] ?? 'http://localhost:3001'
24
+ const DAEMON_API_KEY = process.env['INTUTIC_API_KEY'] ?? ''
25
+ const WORKSPACE_ID = process.env['INTUTIC_WORKSPACE_ID'] ?? ''
26
+
27
+ export interface McpServerConfig {
28
+ name: string
29
+ url: string
30
+ credentialExpiryAt?: Date
31
+ }
32
+
33
+ export interface McpServerHealth {
34
+ serverName: string
35
+ status: 'healthy' | 'degraded' | 'unreachable'
36
+ p95LatencyMs?: number
37
+ errorRatePct?: number
38
+ credentialExpiryAt?: string
39
+ checkedAt: string
40
+ }
41
+
42
+ const servers: McpServerConfig[] = []
43
+ const latestHealth = new Map<string, McpServerHealth>()
44
+ let timer: ReturnType<typeof setInterval> | null = null
45
+
46
+ function discoverServers(): McpServerConfig[] {
47
+ const discovered: McpServerConfig[] = []
48
+ const homedir = os.homedir()
49
+
50
+ const configPaths = [
51
+ path.join(homedir, '.claude', 'mcp.json'),
52
+ process.platform === 'darwin'
53
+ ? path.join(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
54
+ : process.platform === 'win32'
55
+ ? path.join(process.env['APPDATA'] ?? '', 'Claude', 'claude_desktop_config.json')
56
+ : path.join(homedir, '.config', 'Claude', 'claude_desktop_config.json'),
57
+ process.platform === 'darwin'
58
+ ? path.join(homedir, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json')
59
+ : process.platform === 'win32'
60
+ ? path.join(process.env['APPDATA'] ?? '', 'Cursor', 'User', 'globalSettings.json')
61
+ : path.join(homedir, '.config', 'Cursor', 'User', 'globalSettings.json'),
62
+ ]
63
+
64
+ for (const configPath of configPaths) {
65
+ try {
66
+ if (!fs.existsSync(configPath)) continue
67
+ const raw = fs.readFileSync(configPath, 'utf8')
68
+ const parsed = JSON.parse(raw)
69
+ const mcpServers = parsed.mcpServers ?? parsed.mcp ?? {}
70
+ for (const [name, entry] of Object.entries(mcpServers)) {
71
+ if (name === 'intutic') continue // Skip self
72
+ const serverEntry = entry as any
73
+
74
+ let url = ''
75
+ if (serverEntry.url) {
76
+ url = serverEntry.url
77
+ } else if (serverEntry.command) {
78
+ url = `stdio://${serverEntry.command}`
79
+ }
80
+
81
+ if (url && !discovered.some(s => s.name === name)) {
82
+ discovered.push({
83
+ name,
84
+ url,
85
+ credentialExpiryAt: undefined
86
+ })
87
+ }
88
+ }
89
+ } catch {}
90
+ }
91
+
92
+ return discovered
93
+ }
94
+
95
+ async function probeServer(server: McpServerConfig): Promise<McpServerHealth> {
96
+ const start = Date.now()
97
+ if (server.url.startsWith('stdio://')) {
98
+ const cmd = server.url.replace('stdio://', '')
99
+ let status: 'healthy' | 'unreachable' = 'healthy'
100
+ try {
101
+ const whichCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`
102
+ execSync(whichCmd, { stdio: 'ignore' })
103
+ } catch {
104
+ status = 'unreachable'
105
+ }
106
+ return {
107
+ serverName: server.name,
108
+ status,
109
+ p95LatencyMs: Date.now() - start,
110
+ credentialExpiryAt: server.credentialExpiryAt?.toISOString(),
111
+ checkedAt: new Date().toISOString()
112
+ }
113
+ }
114
+
115
+ return new Promise((resolve) => {
116
+ try {
117
+ const url = new URL(server.url)
118
+ const isHttps = url.protocol === 'https:'
119
+ const lib = isHttps ? https : http
120
+ const req = lib.request(
121
+ { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: 'GET' },
122
+ (res) => {
123
+ res.resume()
124
+ const latency = Date.now() - start
125
+ const status = res.statusCode && res.statusCode < 500 ? 'healthy' : 'degraded'
126
+ resolve({ serverName: server.name, status, p95LatencyMs: latency,
127
+ credentialExpiryAt: server.credentialExpiryAt?.toISOString(),
128
+ checkedAt: new Date().toISOString() })
129
+ }
130
+ )
131
+ req.on('error', () => resolve({ serverName: server.name, status: 'unreachable',
132
+ checkedAt: new Date().toISOString() }))
133
+ req.setTimeout(PROBE_TIMEOUT, () => { req.destroy(); resolve({
134
+ serverName: server.name, status: 'unreachable', checkedAt: new Date().toISOString() }) })
135
+ req.end()
136
+ } catch {
137
+ resolve({ serverName: server.name, status: 'unreachable', checkedAt: new Date().toISOString() })
138
+ }
139
+ })
140
+ }
141
+
142
+ async function uploadSnapshots(snapshots: McpServerHealth[]): Promise<void> {
143
+ if (snapshots.length === 0 || !WORKSPACE_ID) return
144
+ const body = JSON.stringify({ mcpServers: snapshots })
145
+ const url = new URL('/api/v1/mcp-daemon/health-snapshot', CP_URL)
146
+ const isHttps = url.protocol === 'https:'
147
+ const lib = isHttps ? https : http
148
+
149
+ return new Promise((resolve) => {
150
+ const req = lib.request(
151
+ {
152
+ hostname: url.hostname,
153
+ port: url.port,
154
+ path: url.pathname,
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ 'Content-Length': Buffer.byteLength(body),
159
+ 'Authorization': `Bearer ${DAEMON_API_KEY}`,
160
+ 'x-workspace-id': WORKSPACE_ID
161
+ }
162
+ },
163
+ (res: any) => {
164
+ res.resume()
165
+ resolve()
166
+ }
167
+ )
168
+ req.on('error', () => resolve())
169
+ req.setTimeout(5000, () => { req.destroy(); resolve() })
170
+ req.write(body)
171
+ req.end()
172
+ })
173
+ }
174
+
175
+ export function registerServer(server: McpServerConfig): void {
176
+ servers.push(server)
177
+ }
178
+
179
+ export function startHealthMonitor(): void {
180
+ // Run config discovery
181
+ const discovered = discoverServers()
182
+ for (const s of discovered) {
183
+ registerServer(s)
184
+ }
185
+
186
+ timer = setInterval(async () => {
187
+ const snapshots: McpServerHealth[] = []
188
+ for (const server of servers) {
189
+ const health = await probeServer(server)
190
+ const prev = latestHealth.get(server.name)
191
+ latestHealth.set(server.name, health)
192
+ snapshots.push(health)
193
+ if (health.status === 'unreachable' && prev?.status !== 'unreachable') {
194
+ logger.warn({ serverName: server.name }, 'mcp_daemon.mcp_server_down')
195
+ }
196
+ }
197
+ if (snapshots.length > 0) {
198
+ await uploadSnapshots(snapshots)
199
+ }
200
+ }, HEARTBEAT_MS)
201
+ timer.unref()
202
+ }
203
+
204
+ export function stopHealthMonitor(): void {
205
+ if (timer) { clearInterval(timer); timer = null }
206
+ }
207
+
208
+ export function getHealthSnapshot(): McpServerHealth[] {
209
+ return Array.from(latestHealth.values())
210
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * intutic-mcp-daemon entry point
3
+ *
4
+ * Long-lived process listening on ~/.intutic/mcp-proxy.sock.
5
+ * Manages policy cache, telemetry batching, and MCP server health monitoring.
6
+ *
7
+ * LLD #28: MCP Daemon Mode, WS-5MCP
8
+ *
9
+ * Usage:
10
+ * node dist/daemon/index.js
11
+ * (managed by LaunchAgent / systemd via `intutic daemon mcp start`)
12
+ *
13
+ * @module
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+ import { createLogger } from '@intutic/logger'
19
+ import { startBatcher, stopBatcher } from './telemetryBatcher.js'
20
+ import { startHealthMonitor, stopHealthMonitor } from './healthMonitor.js'
21
+ import { createSocketServer, getSocketPath } from './socketServer.js'
22
+
23
+ const logger = createLogger('intutic-mcp-daemon')
24
+ const PID_FILE = path.join(os.homedir(), '.intutic', 'mcp-daemon.pid')
25
+
26
+ function ensureDir(dir: string): void {
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
28
+ }
29
+
30
+ async function main(): Promise<void> {
31
+ const socketPath = getSocketPath()
32
+ logger.info({ pid: process.pid, socketPath }, 'mcp_daemon.starting')
33
+
34
+ // Ensure ~/.intutic directory exists with correct permissions
35
+ ensureDir(path.dirname(socketPath))
36
+
37
+ // Remove stale socket file
38
+ try { fs.unlinkSync(socketPath) } catch {}
39
+
40
+ // Write PID file
41
+ fs.writeFileSync(PID_FILE, String(process.pid), { mode: 0o600 })
42
+
43
+ // Start subsystems
44
+ startBatcher()
45
+ startHealthMonitor()
46
+
47
+ const server = createSocketServer()
48
+ server.listen(socketPath, () => {
49
+ // Restrict socket to owner only
50
+ try { fs.chmodSync(socketPath, 0o600) } catch {}
51
+ logger.info({ socketPath, pid: process.pid }, 'mcp_daemon.running')
52
+ process.stdout.write(JSON.stringify({ event: 'mcp_daemon.started', pid: process.pid }) + '\n')
53
+ })
54
+
55
+ server.on('error', (err) => {
56
+ logger.error({ err }, 'mcp_daemon.socket_error')
57
+ process.exit(1)
58
+ })
59
+
60
+ // Graceful shutdown
61
+ const shutdown = async (signal: string): Promise<void> => {
62
+ logger.info({ signal }, 'mcp_daemon.stopping')
63
+ server.close()
64
+ stopHealthMonitor()
65
+ await stopBatcher()
66
+ try { fs.unlinkSync(socketPath) } catch {}
67
+ try { fs.unlinkSync(PID_FILE) } catch {}
68
+ logger.info('mcp_daemon.stopped')
69
+ process.exit(0)
70
+ }
71
+
72
+ process.on('SIGTERM', () => { void shutdown('SIGTERM') })
73
+ process.on('SIGINT', () => { void shutdown('SIGINT') })
74
+ }
75
+
76
+ void main()
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Policy Cache — LRU in-memory + Valkey write-through
3
+ *
4
+ * Resolves PCAS permissions + active SOP rules for MCP daemon.
5
+ * Cache hit: < 0.1ms (LRU). Valkey hit: < 2ms. Miss: HTTP to control-plane.
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 { Redis } from 'ioredis'
13
+ import { createLogger } from '@intutic/logger'
14
+
15
+ const logger = createLogger('mcp-proxy.policyCache')
16
+
17
+ const getPolicyTtlMs = () => parseInt(process.env['MCP_DAEMON_POLICY_TTL_MS'] ?? '300000', 10)
18
+ const getMaxEntries = () => parseInt(process.env['MCP_DAEMON_MAX_CACHE_ENTRIES'] ?? '500', 10)
19
+ const getCpUrl = () => process.env['CONTROL_PLANE_URL'] ?? 'http://localhost:3001'
20
+ const getDaemonApiKey = () => process.env['INTUTIC_API_KEY'] ?? ''
21
+
22
+ const VALKEY_URL = process.env['VALKEY_URL'] ?? process.env['REDIS_URL'] ?? 'redis://localhost:6379'
23
+ const valkey = new Redis(VALKEY_URL, {
24
+ lazyConnect: true,
25
+ maxRetriesPerRequest: 3,
26
+ })
27
+
28
+ valkey.on('error', (err: any) => {
29
+ logger.warn({ err: err.message }, 'policyCache Valkey connection error')
30
+ })
31
+
32
+ export interface ResolvedPolicy {
33
+ workspaceId: string
34
+ sopRules: Record<string, unknown>[]
35
+ dlpPatterns: string[]
36
+ interventionMode: string
37
+ cachedAt: number
38
+ }
39
+
40
+ // Simple LRU map (insertion-order eviction)
41
+ const lru = new Map<string, ResolvedPolicy>()
42
+
43
+ function evictIfFull(): void {
44
+ if (lru.size >= getMaxEntries()) {
45
+ const firstKey = lru.keys().next().value as string
46
+ lru.delete(firstKey)
47
+ }
48
+ }
49
+
50
+ function isStale(entry: ResolvedPolicy): boolean {
51
+ return Date.now() - entry.cachedAt > getPolicyTtlMs()
52
+ }
53
+
54
+ async function fetchFromControlPlane(workspaceId: string): Promise<ResolvedPolicy | null> {
55
+ return new Promise((resolve) => {
56
+ const path = `/api/v1/policy/resolve?workspaceId=${encodeURIComponent(workspaceId)}`
57
+ const url = new URL(path, getCpUrl())
58
+ const lib = url.protocol === 'https:' ? https : http
59
+ const req = lib.request(
60
+ { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: 'GET',
61
+ headers: { 'Authorization': `Bearer ${getDaemonApiKey()}`, 'Accept': 'application/json' } },
62
+ (res: any) => {
63
+ let data = ''
64
+ res.on('data', (c: string) => { data += c })
65
+ res.on('end', () => {
66
+ try {
67
+ const parsed = JSON.parse(data) as Partial<ResolvedPolicy>
68
+ resolve({ workspaceId, sopRules: parsed.sopRules ?? [], dlpPatterns: parsed.dlpPatterns ?? [],
69
+ interventionMode: parsed.interventionMode ?? 'BYPASS', cachedAt: Date.now() })
70
+ } catch { resolve(null) }
71
+ })
72
+ }
73
+ )
74
+ req.on('error', () => resolve(null))
75
+ req.setTimeout(5000, () => { req.destroy(); resolve(null) })
76
+ req.end()
77
+ })
78
+ }
79
+
80
+ /**
81
+ * Resolves policy for a workspace.
82
+ * Order: LRU (fresh) → LRU (stale, triggers async refresh) → Valkey → HTTP fetch
83
+ */
84
+ export async function resolvePolicy(workspaceId: string): Promise<ResolvedPolicy | null> {
85
+ const cached = lru.get(workspaceId)
86
+
87
+ if (cached && !isStale(cached)) {
88
+ // Touch for LRU recency
89
+ lru.delete(workspaceId)
90
+ lru.set(workspaceId, cached)
91
+ return cached
92
+ }
93
+
94
+ if (cached && isStale(cached)) {
95
+ // Return stale, trigger async refresh
96
+ void fetchFromControlPlane(workspaceId).then(async (fresh) => {
97
+ if (fresh) {
98
+ lru.delete(workspaceId)
99
+ evictIfFull()
100
+ lru.set(workspaceId, fresh)
101
+ try {
102
+ await valkey.set(`mcp_daemon:policy:${workspaceId}`, JSON.stringify(fresh), 'PX', getPolicyTtlMs())
103
+ } catch {}
104
+ }
105
+ })
106
+ return cached
107
+ }
108
+
109
+ // Cache miss in LRU — check Valkey
110
+ try {
111
+ const valkeyCached = await valkey.get(`mcp_daemon:policy:${workspaceId}`)
112
+ if (valkeyCached) {
113
+ const parsed = JSON.parse(valkeyCached) as ResolvedPolicy
114
+ evictIfFull()
115
+ lru.set(workspaceId, parsed)
116
+ return parsed
117
+ }
118
+ } catch {}
119
+
120
+ // Cache miss in Valkey too — fetch synchronously
121
+ logger.debug({ workspaceId }, 'policy_cache.miss')
122
+ const fresh = await fetchFromControlPlane(workspaceId)
123
+ if (fresh) {
124
+ evictIfFull()
125
+ lru.set(workspaceId, fresh)
126
+ try {
127
+ await valkey.set(`mcp_daemon:policy:${workspaceId}`, JSON.stringify(fresh), 'PX', getPolicyTtlMs())
128
+ } catch {}
129
+ }
130
+ return fresh
131
+ }
132
+
133
+ /** Invalidates policy cache for a workspace (called on SOP update). */
134
+ export function invalidatePolicy(workspaceId: string): void {
135
+ lru.delete(workspaceId)
136
+ valkey.del(`mcp_daemon:policy:${workspaceId}`, `mcp_daemon:sop_rules:${workspaceId}`).catch(() => {})
137
+ logger.info({ workspaceId }, 'policy_cache.invalidated')
138
+ }
139
+
140
+ /** Returns cache statistics. */
141
+ export function getCacheStats(): { entries: number; hitRate: number } {
142
+ return { entries: lru.size, hitRate: 0 } // hit rate tracked by metrics in production
143
+ }