@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.
@@ -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
- })