@intutic/mcp-governance-proxy 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* interceptor.test.ts — Unit tests for the MCP governance proxy interceptor.
|
|
3
|
-
*
|
|
4
|
-
* Zero vi.mock — tests the actual decision engine with in-process policy stubs.
|
|
5
|
-
*
|
|
6
|
-
* @module
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
10
|
-
import { ToolCallInterceptor } from '../interceptor.js'
|
|
11
|
-
import { PolicyClient } from '../policy.js'
|
|
12
|
-
import type { SopRule } from '../policy.js'
|
|
13
|
-
import { GovernanceEmitter } from '../emitter.js'
|
|
14
|
-
import * as node_path from 'node:path'
|
|
15
|
-
import * as node_os from 'node:os'
|
|
16
|
-
|
|
17
|
-
// ─── Minimal in-process stub clients ─────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
class StubPolicyClient extends PolicyClient {
|
|
20
|
-
private _rules: SopRule[] = []
|
|
21
|
-
|
|
22
|
-
constructor(rules: SopRule[] = []) {
|
|
23
|
-
// Pass dummy values — we override getRules() and matchRule()
|
|
24
|
-
super('http://localhost:0', '', 'test-ws', 60_000)
|
|
25
|
-
this._rules = rules
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
override getRules(): readonly SopRule[] {
|
|
29
|
-
return this._rules
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
override matchRule(toolName: string, toolInputJson: string): SopRule | null {
|
|
33
|
-
for (const rule of this._rules) {
|
|
34
|
-
try {
|
|
35
|
-
if (!new RegExp(rule.toolPattern).test(toolName)) continue
|
|
36
|
-
if (rule.argPattern && !new RegExp(rule.argPattern).test(toolInputJson)) continue
|
|
37
|
-
return rule
|
|
38
|
-
} catch {
|
|
39
|
-
continue
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
override start(): void { /* no-op */ }
|
|
46
|
-
override stop(): void { /* no-op */ }
|
|
47
|
-
override async refresh(): Promise<void> { /* no-op */ }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
class StubEmitter extends GovernanceEmitter {
|
|
51
|
-
readonly emitted: Array<{ kind: string; toolName: string; toolInput: unknown; reason?: string }> = []
|
|
52
|
-
|
|
53
|
-
constructor() {
|
|
54
|
-
super('http://localhost:0', '', node_path.join(node_os.homedir(), '.intutic-test', 'events.jsonl'), 'test-ws')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
override emit(kind: 'tool_allowed' | 'tool_blocked' | 'tool_redacted', toolName: string, toolInput: unknown, reason?: string): void {
|
|
58
|
-
this.emitted.push({ kind, toolName, toolInput, reason })
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
describe('ToolCallInterceptor', () => {
|
|
65
|
-
let emitter: StubEmitter
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
emitter = new StubEmitter()
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
describe('DLP scanning', () => {
|
|
72
|
-
it('blocks tool calls containing OpenAI API keys', async () => {
|
|
73
|
-
const policy = new StubPolicyClient()
|
|
74
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
75
|
-
|
|
76
|
-
const decision = await interceptor.decide('Bash', { command: 'echo sk-abc123def456ghi789jkl012mno345pqr' })
|
|
77
|
-
expect(decision.action).toBe('block')
|
|
78
|
-
expect(emitter.emitted).toHaveLength(1)
|
|
79
|
-
expect(emitter.emitted[0]?.kind).toBe('tool_blocked')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('blocks tool calls containing Anthropic API keys', async () => {
|
|
83
|
-
const policy = new StubPolicyClient()
|
|
84
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
85
|
-
|
|
86
|
-
const decision = await interceptor.decide('Write', { content: 'key=sk-ant-api03-verylongantkeyhere12345678901234' })
|
|
87
|
-
expect(decision.action).toBe('block')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('blocks rm -rf / commands', async () => {
|
|
91
|
-
const policy = new StubPolicyClient()
|
|
92
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
93
|
-
|
|
94
|
-
const decision = await interceptor.decide('Bash', { command: 'rm -rf /' })
|
|
95
|
-
expect(decision.action).toBe('block')
|
|
96
|
-
expect((decision as { action: 'block'; reason: string }).reason).toContain('Destructive')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('blocks SQL DROP TABLE', async () => {
|
|
100
|
-
const policy = new StubPolicyClient()
|
|
101
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
102
|
-
|
|
103
|
-
const decision = await interceptor.decide('mcp__database__execute', { query: 'DROP TABLE users' })
|
|
104
|
-
expect(decision.action).toBe('block')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('allows benign tool calls with no DLP match', async () => {
|
|
108
|
-
const policy = new StubPolicyClient()
|
|
109
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
110
|
-
|
|
111
|
-
const decision = await interceptor.decide('Read', { path: '/tmp/hello.txt' })
|
|
112
|
-
expect(decision.action).toBe('allow')
|
|
113
|
-
expect(emitter.emitted[0]?.kind).toBe('tool_allowed')
|
|
114
|
-
})
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
describe('SOP policy rules', () => {
|
|
118
|
-
it('blocks tool when matching block rule exists', async () => {
|
|
119
|
-
const rules: SopRule[] = [{
|
|
120
|
-
id: 'rule-1',
|
|
121
|
-
toolPattern: 'Bash',
|
|
122
|
-
action: 'block',
|
|
123
|
-
reason: 'Bash execution not permitted in this workspace',
|
|
124
|
-
}]
|
|
125
|
-
const policy = new StubPolicyClient(rules)
|
|
126
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
127
|
-
|
|
128
|
-
const decision = await interceptor.decide('Bash', { command: 'ls -la' })
|
|
129
|
-
expect(decision.action).toBe('block')
|
|
130
|
-
expect((decision as { action: 'block'; reason: string }).reason).toContain('Bash execution')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('allows tool when pattern does not match', async () => {
|
|
134
|
-
const rules: SopRule[] = [{
|
|
135
|
-
id: 'rule-1',
|
|
136
|
-
toolPattern: 'Bash',
|
|
137
|
-
action: 'block',
|
|
138
|
-
reason: 'Bash blocked',
|
|
139
|
-
}]
|
|
140
|
-
const policy = new StubPolicyClient(rules)
|
|
141
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
142
|
-
|
|
143
|
-
const decision = await interceptor.decide('Read', { path: '/tmp/safe.txt' })
|
|
144
|
-
expect(decision.action).toBe('allow')
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('blocks when arg pattern also matches', async () => {
|
|
148
|
-
const rules: SopRule[] = [{
|
|
149
|
-
id: 'rule-2',
|
|
150
|
-
toolPattern: 'mcp__.*',
|
|
151
|
-
argPattern: 'prod.*database',
|
|
152
|
-
action: 'block',
|
|
153
|
-
reason: 'Production database access blocked',
|
|
154
|
-
}]
|
|
155
|
-
const policy = new StubPolicyClient(rules)
|
|
156
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
157
|
-
|
|
158
|
-
const decision = await interceptor.decide('mcp__postgres__query', { dsn: 'prod-us-east-1-database' })
|
|
159
|
-
expect(decision.action).toBe('block')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('allows when arg pattern does NOT match', async () => {
|
|
163
|
-
const rules: SopRule[] = [{
|
|
164
|
-
id: 'rule-2',
|
|
165
|
-
toolPattern: 'mcp__.*',
|
|
166
|
-
argPattern: 'prod.*database',
|
|
167
|
-
action: 'block',
|
|
168
|
-
reason: 'Production database access blocked',
|
|
169
|
-
}]
|
|
170
|
-
const policy = new StubPolicyClient(rules)
|
|
171
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
172
|
-
|
|
173
|
-
const decision = await interceptor.decide('mcp__postgres__query', { dsn: 'dev-local-db' })
|
|
174
|
-
expect(decision.action).toBe('allow')
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('treats require_approval as block (headless proxy)', async () => {
|
|
178
|
-
const rules: SopRule[] = [{
|
|
179
|
-
id: 'rule-3',
|
|
180
|
-
toolPattern: 'Write',
|
|
181
|
-
action: 'require_approval',
|
|
182
|
-
reason: 'File writes require review',
|
|
183
|
-
}]
|
|
184
|
-
const policy = new StubPolicyClient(rules)
|
|
185
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
186
|
-
|
|
187
|
-
const decision = await interceptor.decide('Write', { path: '/etc/passwd', content: 'test' })
|
|
188
|
-
expect(decision.action).toBe('block')
|
|
189
|
-
expect((decision as { action: 'block'; reason: string }).reason).toContain('human approval')
|
|
190
|
-
})
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
describe('fail-open behavior', () => {
|
|
194
|
-
it('allows when policy client throws (failOpen=true)', async () => {
|
|
195
|
-
const policy = new StubPolicyClient()
|
|
196
|
-
// Override matchRule to throw — simulates policy engine failure
|
|
197
|
-
policy.matchRule = () => { throw new Error('Policy engine down') }
|
|
198
|
-
|
|
199
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, true)
|
|
200
|
-
const decision = await interceptor.decide('Read', { path: '/tmp/ok.txt' })
|
|
201
|
-
expect(decision.action).toBe('allow')
|
|
202
|
-
})
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
describe('fail-closed behavior', () => {
|
|
206
|
-
it('blocks when policy client throws (failOpen=false)', async () => {
|
|
207
|
-
const policy = new StubPolicyClient()
|
|
208
|
-
policy.matchRule = () => { throw new Error('Policy engine down') }
|
|
209
|
-
|
|
210
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, false)
|
|
211
|
-
const decision = await interceptor.decide('Read', { path: '/tmp/ok.txt' })
|
|
212
|
-
expect(decision.action).toBe('block')
|
|
213
|
-
expect((decision as { action: 'block'; reason: string }).reason).toContain('control plane unreachable')
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
it('blocks when DLP scan throws (failOpen=false)', async () => {
|
|
217
|
-
const policy = new StubPolicyClient()
|
|
218
|
-
const interceptor = new ToolCallInterceptor(policy, emitter, false)
|
|
219
|
-
|
|
220
|
-
// Circular references cause JSON.stringify / scanning to throw or error
|
|
221
|
-
const circular: any = {}
|
|
222
|
-
circular.self = circular
|
|
223
|
-
|
|
224
|
-
const decision = await interceptor.decide('Read', circular)
|
|
225
|
-
expect(decision.action).toBe('block')
|
|
226
|
-
expect((decision as { action: 'block'; reason: string }).reason).toContain('control plane unreachable')
|
|
227
|
-
})
|
|
228
|
-
})
|
|
229
|
-
})
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
-
import * as http from 'node:http'
|
|
3
|
-
import * as net from 'node:net'
|
|
4
|
-
|
|
5
|
-
describe('policyCache Unit Tests', () => {
|
|
6
|
-
let mockServer: http.Server
|
|
7
|
-
let port: number
|
|
8
|
-
let requestCount = 0
|
|
9
|
-
let latestWorkspaceId: string | null = null
|
|
10
|
-
|
|
11
|
-
let resolvePolicy: any
|
|
12
|
-
let invalidatePolicy: any
|
|
13
|
-
let getCacheStats: any
|
|
14
|
-
|
|
15
|
-
beforeAll(async () => {
|
|
16
|
-
mockServer = http.createServer((req, res) => {
|
|
17
|
-
requestCount++
|
|
18
|
-
const url = new URL(req.url ?? '', `http://${req.headers.host}`)
|
|
19
|
-
latestWorkspaceId = url.searchParams.get('workspaceId')
|
|
20
|
-
|
|
21
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
22
|
-
res.end(
|
|
23
|
-
JSON.stringify({
|
|
24
|
-
workspaceId: latestWorkspaceId,
|
|
25
|
-
sopRules: [
|
|
26
|
-
{
|
|
27
|
-
id: 'rule_1',
|
|
28
|
-
toolPattern: 'Bash',
|
|
29
|
-
action: 'block',
|
|
30
|
-
reason: 'No destructive commands allowed',
|
|
31
|
-
},
|
|
32
|
-
],
|
|
33
|
-
dlpPatterns: [],
|
|
34
|
-
interventionMode: 'BLOCK',
|
|
35
|
-
})
|
|
36
|
-
)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
await new Promise<void>((resolve) => {
|
|
40
|
-
mockServer.listen(0, '127.0.0.1', () => {
|
|
41
|
-
const addr = mockServer.address() as net.AddressInfo | null
|
|
42
|
-
port = addr?.port ?? 3001
|
|
43
|
-
process.env['CONTROL_PLANE_URL'] = `http://127.0.0.1:${port}`
|
|
44
|
-
resolve()
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
// Import after process.env is set to ensure it uses the mock URL
|
|
49
|
-
const mod = await import('../../daemon/policyCache.js')
|
|
50
|
-
resolvePolicy = mod.resolvePolicy
|
|
51
|
-
invalidatePolicy = mod.invalidatePolicy
|
|
52
|
-
getCacheStats = mod.getCacheStats
|
|
53
|
-
|
|
54
|
-
// Clean up any stale cache from prior runs in Valkey
|
|
55
|
-
invalidatePolicy('ws_test_cache_miss')
|
|
56
|
-
requestCount = 0
|
|
57
|
-
latestWorkspaceId = null
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
afterAll(async () => {
|
|
61
|
-
await new Promise<void>((resolve) => mockServer.close(() => resolve()))
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('fetches policy from control plane on cache miss', async () => {
|
|
65
|
-
const wsId = 'ws_test_cache_miss'
|
|
66
|
-
const policy = await resolvePolicy(wsId)
|
|
67
|
-
expect(policy).not.toBeNull()
|
|
68
|
-
expect(policy!.workspaceId).toBe(wsId)
|
|
69
|
-
expect(policy!.sopRules).toHaveLength(1)
|
|
70
|
-
expect(policy!.sopRules[0]!.id).toBe('rule_1')
|
|
71
|
-
expect(requestCount).toBe(1)
|
|
72
|
-
expect(latestWorkspaceId).toBe(wsId)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('serves policy from memory on cache hit', async () => {
|
|
76
|
-
const wsId = 'ws_test_cache_miss' // reuse the same wsId
|
|
77
|
-
const policy = await resolvePolicy(wsId)
|
|
78
|
-
expect(policy).not.toBeNull()
|
|
79
|
-
expect(requestCount).toBe(1) // Request count remains 1 due to cache hit
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('invalidates policy correctly', async () => {
|
|
83
|
-
const wsId = 'ws_test_cache_miss'
|
|
84
|
-
invalidatePolicy(wsId)
|
|
85
|
-
|
|
86
|
-
const policy = await resolvePolicy(wsId)
|
|
87
|
-
expect(policy).not.toBeNull()
|
|
88
|
-
expect(requestCount).toBe(2) // Request count increments to 2 on new fetch
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('tracks cache entries stats correctly', () => {
|
|
92
|
-
const stats = getCacheStats()
|
|
93
|
-
expect(stats.entries).toBeGreaterThanOrEqual(1)
|
|
94
|
-
})
|
|
95
|
-
})
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
-
import * as net from 'node:net'
|
|
3
|
-
import * as path from 'node:path'
|
|
4
|
-
import * as os from 'node:os'
|
|
5
|
-
import * as fs from 'node:fs/promises'
|
|
6
|
-
import { createSocketServer } from '../../daemon/socketServer.js'
|
|
7
|
-
|
|
8
|
-
describe('socketServer Unit Tests', () => {
|
|
9
|
-
const socketPath = path.join(os.tmpdir(), `mcp-proxy-test-${Date.now()}.sock`)
|
|
10
|
-
let server: net.Server
|
|
11
|
-
|
|
12
|
-
beforeAll(async () => {
|
|
13
|
-
process.env['MCP_DAEMON_SOCKET'] = socketPath
|
|
14
|
-
await fs.rm(socketPath, { force: true })
|
|
15
|
-
server = createSocketServer()
|
|
16
|
-
await new Promise<void>((resolve) => server.listen(socketPath, () => resolve()))
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
afterAll(async () => {
|
|
20
|
-
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
21
|
-
await fs.rm(socketPath, { force: true })
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('binds to unix socket and responds to proxy.health_check JSON-RPC', async () => {
|
|
25
|
-
const response = await new Promise<any>((resolve, reject) => {
|
|
26
|
-
const client = net.createConnection(socketPath)
|
|
27
|
-
client.setEncoding('utf8')
|
|
28
|
-
|
|
29
|
-
let buffer = ''
|
|
30
|
-
const req = {
|
|
31
|
-
jsonrpc: '2.0',
|
|
32
|
-
id: 'req_123',
|
|
33
|
-
method: 'proxy.health_check',
|
|
34
|
-
params: {},
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
client.on('connect', () => {
|
|
38
|
-
client.write(JSON.stringify(req) + '\n')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
client.on('data', (chunk) => {
|
|
42
|
-
buffer += chunk
|
|
43
|
-
if (buffer.includes('\n')) {
|
|
44
|
-
try {
|
|
45
|
-
resolve(JSON.parse(buffer.trim()))
|
|
46
|
-
} catch (err) {
|
|
47
|
-
reject(err)
|
|
48
|
-
} finally {
|
|
49
|
-
client.end()
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
client.on('error', reject)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
expect(response.jsonrpc).toBe('2.0')
|
|
58
|
-
expect(response.id).toBe('req_123')
|
|
59
|
-
expect(response.result).toBeDefined()
|
|
60
|
-
expect(response.result.status).toBe('ok')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('responds to policy.get JSON-RPC', async () => {
|
|
64
|
-
const response = await new Promise<any>((resolve, reject) => {
|
|
65
|
-
const client = net.createConnection(socketPath)
|
|
66
|
-
client.setEncoding('utf8')
|
|
67
|
-
|
|
68
|
-
let buffer = ''
|
|
69
|
-
const req = {
|
|
70
|
-
jsonrpc: '2.0',
|
|
71
|
-
id: 'req_policy_get',
|
|
72
|
-
method: 'policy.get',
|
|
73
|
-
params: { workspaceId: 'ws_test' },
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
client.on('connect', () => {
|
|
77
|
-
client.write(JSON.stringify(req) + '\n')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
client.on('data', (chunk) => {
|
|
81
|
-
buffer += chunk
|
|
82
|
-
if (buffer.includes('\n')) {
|
|
83
|
-
try {
|
|
84
|
-
resolve(JSON.parse(buffer.trim()))
|
|
85
|
-
} catch (err) {
|
|
86
|
-
reject(err)
|
|
87
|
-
} finally {
|
|
88
|
-
client.end()
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
client.on('error', reject)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
expect(response.jsonrpc).toBe('2.0')
|
|
97
|
-
expect(response.id).toBe('req_policy_get')
|
|
98
|
-
expect(response.result).toBeDefined()
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('responds to policy.invalidate JSON-RPC', async () => {
|
|
102
|
-
const response = await new Promise<any>((resolve, reject) => {
|
|
103
|
-
const client = net.createConnection(socketPath)
|
|
104
|
-
client.setEncoding('utf8')
|
|
105
|
-
|
|
106
|
-
let buffer = ''
|
|
107
|
-
const req = {
|
|
108
|
-
jsonrpc: '2.0',
|
|
109
|
-
id: 'req_policy_invalidate',
|
|
110
|
-
method: 'policy.invalidate',
|
|
111
|
-
params: { workspaceId: 'ws_test' },
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
client.on('connect', () => {
|
|
115
|
-
client.write(JSON.stringify(req) + '\n')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
client.on('data', (chunk) => {
|
|
119
|
-
buffer += chunk
|
|
120
|
-
if (buffer.includes('\n')) {
|
|
121
|
-
try {
|
|
122
|
-
resolve(JSON.parse(buffer.trim()))
|
|
123
|
-
} catch (err) {
|
|
124
|
-
reject(err)
|
|
125
|
-
} finally {
|
|
126
|
-
client.end()
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
client.on('error', reject)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
expect(response.jsonrpc).toBe('2.0')
|
|
135
|
-
expect(response.id).toBe('req_policy_invalidate')
|
|
136
|
-
expect(response.result.invalidated).toBe(true)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('responds to telemetry.enqueue JSON-RPC', async () => {
|
|
140
|
-
const response = await new Promise<any>((resolve, reject) => {
|
|
141
|
-
const client = net.createConnection(socketPath)
|
|
142
|
-
client.setEncoding('utf8')
|
|
143
|
-
|
|
144
|
-
let buffer = ''
|
|
145
|
-
const req = {
|
|
146
|
-
jsonrpc: '2.0',
|
|
147
|
-
id: 'req_telemetry_enqueue',
|
|
148
|
-
method: 'telemetry.enqueue',
|
|
149
|
-
params: {
|
|
150
|
-
event: 'tool_allowed',
|
|
151
|
-
toolName: 'read_file',
|
|
152
|
-
workspaceId: 'ws_test',
|
|
153
|
-
harnessType: 'mcp-governance-proxy',
|
|
154
|
-
timestamp: new Date().toISOString()
|
|
155
|
-
},
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
client.on('connect', () => {
|
|
159
|
-
client.write(JSON.stringify(req) + '\n')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
client.on('data', (chunk) => {
|
|
163
|
-
buffer += chunk
|
|
164
|
-
if (buffer.includes('\n')) {
|
|
165
|
-
try {
|
|
166
|
-
resolve(JSON.parse(buffer.trim()))
|
|
167
|
-
} catch (err) {
|
|
168
|
-
reject(err)
|
|
169
|
-
} finally {
|
|
170
|
-
client.end()
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
client.on('error', reject)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
expect(response.jsonrpc).toBe('2.0')
|
|
179
|
-
expect(response.id).toBe('req_telemetry_enqueue')
|
|
180
|
-
expect(response.result.queued).toBe(true)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('responds to proxy.tool_call JSON-RPC', async () => {
|
|
184
|
-
const response = await new Promise<any>((resolve, reject) => {
|
|
185
|
-
const client = net.createConnection(socketPath)
|
|
186
|
-
client.setEncoding('utf8')
|
|
187
|
-
|
|
188
|
-
let buffer = ''
|
|
189
|
-
const req = {
|
|
190
|
-
jsonrpc: '2.0',
|
|
191
|
-
id: 'req_tool_call',
|
|
192
|
-
method: 'proxy.tool_call',
|
|
193
|
-
params: { workspaceId: 'ws_test' },
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
client.on('connect', () => {
|
|
197
|
-
client.write(JSON.stringify(req) + '\n')
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
client.on('data', (chunk) => {
|
|
201
|
-
buffer += chunk
|
|
202
|
-
if (buffer.includes('\n')) {
|
|
203
|
-
try {
|
|
204
|
-
resolve(JSON.parse(buffer.trim()))
|
|
205
|
-
} catch (err) {
|
|
206
|
-
reject(err)
|
|
207
|
-
} finally {
|
|
208
|
-
client.end()
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
client.on('error', reject)
|
|
214
|
-
})
|
|
215
|
-
|
|
216
|
-
expect(response.jsonrpc).toBe('2.0')
|
|
217
|
-
expect(response.id).toBe('req_tool_call')
|
|
218
|
-
expect(response.result.allowed).toBeDefined()
|
|
219
|
-
})
|
|
220
|
-
})
|
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
})
|