@intutic/mcp-governance-proxy 1.4.1 → 1.6.0
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/package.json +26 -12
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-test.log +0 -51
- package/.turbo/turbo-typecheck.log +0 -1
- package/dummy-path.jsonl +0 -2
- package/src/__tests__/config.test.ts +0 -82
- package/src/__tests__/dlp.test.ts +0 -103
- package/src/__tests__/integration/daemon-shim.test.ts +0 -125
- package/src/__tests__/interceptor.test.ts +0 -229
- package/src/__tests__/unit/policyCache.test.ts +0 -95
- package/src/__tests__/unit/socketServer.test.ts +0 -220
- package/src/__tests__/unit/telemetryBatcher.test.ts +0 -78
- package/src/config.ts +0 -145
- package/src/daemon/healthMonitor.ts +0 -210
- package/src/daemon/index.ts +0 -76
- package/src/daemon/policyCache.ts +0 -143
- package/src/daemon/socketServer.ts +0 -111
- package/src/daemon/telemetryBatcher.ts +0 -147
- package/src/daemonClient.ts +0 -71
- package/src/dlp.ts +0 -72
- package/src/emitter.ts +0 -144
- package/src/index.ts +0 -53
- package/src/interceptor.ts +0 -103
- package/src/policy.ts +0 -152
- package/src/proxy.ts +0 -327
- package/src/stderrLog.ts +0 -56
- package/tsconfig.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -9
|
@@ -1,147 +0,0 @@
|
|
|
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
|
-
}
|
package/src/daemonClient.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
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
|
-
})
|
package/src/interceptor.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* interceptor.ts — tools/call decision engine.
|
|
3
|
-
*
|
|
4
|
-
* Given a tool name and arguments, evaluates:
|
|
5
|
-
* 1. DLP scan (credential / destructive pattern detection)
|
|
6
|
-
* 2. SOP policy rules (fetched from control plane via PolicyClient)
|
|
7
|
-
*
|
|
8
|
-
* Returns an allow / block / redact decision.
|
|
9
|
-
*
|
|
10
|
-
* @module
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createStderrLogger as createLogger } from './stderrLog.js'
|
|
14
|
-
import { scanToolInput, formatDlpBlockReason } from './dlp.js'
|
|
15
|
-
import type { PolicyClient } from './policy.js'
|
|
16
|
-
import type { GovernanceEmitter } from './emitter.js'
|
|
17
|
-
|
|
18
|
-
const log = createLogger('mcp-proxy-interceptor')
|
|
19
|
-
|
|
20
|
-
export type Decision =
|
|
21
|
-
| { action: 'allow' }
|
|
22
|
-
| { action: 'block'; reason: string }
|
|
23
|
-
| { action: 'redact'; reason: string; redactedInput: unknown }
|
|
24
|
-
|
|
25
|
-
export class ToolCallInterceptor {
|
|
26
|
-
constructor(
|
|
27
|
-
private readonly policy: PolicyClient,
|
|
28
|
-
private readonly emitter: GovernanceEmitter,
|
|
29
|
-
private readonly failOpen: boolean = true
|
|
30
|
-
) {}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Evaluate a tools/call request and return a governance decision.
|
|
34
|
-
*
|
|
35
|
-
* @param toolName - The MCP tool name (e.g. "mcp__filesystem__read_file" or "Bash")
|
|
36
|
-
* @param toolInput - The tool_input / arguments object
|
|
37
|
-
* @returns Decision: allow, block, or redact
|
|
38
|
-
*/
|
|
39
|
-
async decide(toolName: string, toolInput: unknown): Promise<Decision> {
|
|
40
|
-
log.debug({ action: 'interceptor_decide', toolName }, 'Evaluating tool call')
|
|
41
|
-
|
|
42
|
-
// 1. DLP scan
|
|
43
|
-
try {
|
|
44
|
-
const dlp = scanToolInput(toolInput)
|
|
45
|
-
if (dlp.hasFinding) {
|
|
46
|
-
const reason = formatDlpBlockReason(dlp.findings)
|
|
47
|
-
log.warn({ action: 'dlp_block', toolName, findings: dlp.findings }, 'DLP block')
|
|
48
|
-
this.emitter.emit('tool_blocked', toolName, toolInput, reason)
|
|
49
|
-
return { action: 'block', reason }
|
|
50
|
-
}
|
|
51
|
-
} catch (err) {
|
|
52
|
-
log.error({ action: 'dlp_error', err: (err as Error).message }, 'DLP scan error — skipping')
|
|
53
|
-
if (!this.failOpen) {
|
|
54
|
-
return {
|
|
55
|
-
action: 'block',
|
|
56
|
-
reason:
|
|
57
|
-
'Governance check failed — Intutic control plane unreachable. ' +
|
|
58
|
-
'Tool call blocked by workspace policy (fail-closed mode). ' +
|
|
59
|
-
'Contact your administrator or update mcpProxyFailBehavior to open.',
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 2. SOP policy rule match
|
|
65
|
-
try {
|
|
66
|
-
const toolInputJson = JSON.stringify(toolInput ?? {})
|
|
67
|
-
const rule = this.policy.matchRule(toolName, toolInputJson)
|
|
68
|
-
if (rule) {
|
|
69
|
-
if (rule.action === 'block') {
|
|
70
|
-
log.warn({ action: 'policy_block', toolName, ruleId: rule.id, reason: rule.reason }, 'Policy block')
|
|
71
|
-
this.emitter.emit('tool_blocked', toolName, toolInput, rule.reason)
|
|
72
|
-
return { action: 'block', reason: rule.reason }
|
|
73
|
-
}
|
|
74
|
-
if (rule.action === 'warn') {
|
|
75
|
-
log.warn({ action: 'policy_warn', toolName, ruleId: rule.id, reason: rule.reason }, 'Policy warning (allowing)')
|
|
76
|
-
// Fall through to allow — warnings are logged only
|
|
77
|
-
}
|
|
78
|
-
// 'require_approval' treated as block in headless proxy (no interactive UI)
|
|
79
|
-
if (rule.action === 'require_approval') {
|
|
80
|
-
const reason = `Tool requires human approval per SOP rule ${rule.id}: ${rule.reason}`
|
|
81
|
-
log.warn({ action: 'policy_approval_required', toolName, ruleId: rule.id }, reason)
|
|
82
|
-
this.emitter.emit('tool_blocked', toolName, toolInput, reason)
|
|
83
|
-
return { action: 'block', reason }
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} catch (err) {
|
|
87
|
-
log.error({ action: 'policy_error', err: (err as Error).message }, 'Policy evaluation error')
|
|
88
|
-
if (!this.failOpen) {
|
|
89
|
-
return {
|
|
90
|
-
action: 'block',
|
|
91
|
-
reason:
|
|
92
|
-
'Governance check failed — Intutic control plane unreachable. ' +
|
|
93
|
-
'Tool call blocked by workspace policy (fail-closed mode). ' +
|
|
94
|
-
'Contact your administrator or update mcpProxyFailBehavior to open.',
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 3. Allow — emit telemetry event
|
|
100
|
-
this.emitter.emit('tool_allowed', toolName, toolInput)
|
|
101
|
-
return { action: 'allow' }
|
|
102
|
-
}
|
|
103
|
-
}
|