@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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-test.log +51 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +100 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/healthMonitor.d.ts +18 -0
- package/dist/daemon/healthMonitor.d.ts.map +1 -0
- package/dist/daemon/healthMonitor.js +185 -0
- package/dist/daemon/healthMonitor.js.map +1 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +77 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/policyCache.d.ts +20 -0
- package/dist/daemon/policyCache.d.ts.map +1 -0
- package/dist/daemon/policyCache.js +124 -0
- package/dist/daemon/policyCache.js.map +1 -0
- package/dist/daemon/socketServer.d.ts +13 -0
- package/dist/daemon/socketServer.d.ts.map +1 -0
- package/dist/daemon/socketServer.js +90 -0
- package/dist/daemon/socketServer.js.map +1 -0
- package/dist/daemon/telemetryBatcher.d.ts +15 -0
- package/dist/daemon/telemetryBatcher.d.ts.map +1 -0
- package/dist/daemon/telemetryBatcher.js +143 -0
- package/dist/daemon/telemetryBatcher.js.map +1 -0
- package/dist/daemonClient.d.ts +5 -0
- package/dist/daemonClient.d.ts.map +1 -0
- package/dist/daemonClient.js +67 -0
- package/dist/daemonClient.js.map +1 -0
- package/dist/dlp.d.ts +29 -0
- package/dist/dlp.d.ts.map +1 -0
- package/dist/dlp.js +58 -0
- package/dist/dlp.js.map +1 -0
- package/dist/emitter.d.ts +34 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +121 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +38 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +93 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/policy.d.ts +41 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +130 -0
- package/dist/policy.js.map +1 -0
- package/dist/proxy.d.ts +43 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +238 -0
- package/dist/proxy.js.map +1 -0
- package/dist/stderrLog.d.ts +32 -0
- package/dist/stderrLog.d.ts.map +1 -0
- package/dist/stderrLog.js +45 -0
- package/dist/stderrLog.js.map +1 -0
- package/dummy-path.jsonl +2 -0
- package/package.json +32 -0
- package/src/__tests__/config.test.ts +82 -0
- package/src/__tests__/dlp.test.ts +103 -0
- package/src/__tests__/integration/daemon-shim.test.ts +125 -0
- package/src/__tests__/interceptor.test.ts +229 -0
- package/src/__tests__/unit/policyCache.test.ts +95 -0
- package/src/__tests__/unit/socketServer.test.ts +220 -0
- package/src/__tests__/unit/telemetryBatcher.test.ts +78 -0
- package/src/config.ts +145 -0
- package/src/daemon/healthMonitor.ts +210 -0
- package/src/daemon/index.ts +76 -0
- package/src/daemon/policyCache.ts +143 -0
- package/src/daemon/socketServer.ts +111 -0
- package/src/daemon/telemetryBatcher.ts +147 -0
- package/src/daemonClient.ts +71 -0
- package/src/dlp.ts +72 -0
- package/src/emitter.ts +144 -0
- package/src/index.ts +53 -0
- package/src/interceptor.ts +103 -0
- package/src/policy.ts +152 -0
- package/src/proxy.ts +327 -0
- package/src/stderrLog.ts +56 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,125 @@
|
|
|
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 * as http from 'node:http'
|
|
7
|
+
import { createSocketServer } from '../../daemon/socketServer.js'
|
|
8
|
+
import { callDaemonSocket } from '../../daemonClient.js'
|
|
9
|
+
import { PolicyClient } from '../../policy.js'
|
|
10
|
+
import { GovernanceEmitter } from '../../emitter.js'
|
|
11
|
+
|
|
12
|
+
describe('Daemon-Shim Integration Tests', () => {
|
|
13
|
+
const socketPath = path.join(os.tmpdir(), `mcp-daemon-shim-test-${Date.now()}.sock`)
|
|
14
|
+
let server: net.Server
|
|
15
|
+
let mockCp: http.Server
|
|
16
|
+
let cpPort: number
|
|
17
|
+
let lastWorkspaceId: string | null = null
|
|
18
|
+
let telemetryEvents: any[] = []
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
process.env['MCP_DAEMON_SOCKET'] = socketPath
|
|
22
|
+
await fs.rm(socketPath, { force: true })
|
|
23
|
+
|
|
24
|
+
// Setup mock control plane HTTP server
|
|
25
|
+
mockCp = http.createServer((req, res) => {
|
|
26
|
+
const url = new URL(req.url ?? '', `http://${req.headers.host}`)
|
|
27
|
+
if (url.pathname === '/api/v1/policy/resolve') {
|
|
28
|
+
lastWorkspaceId = url.searchParams.get('workspaceId')
|
|
29
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
30
|
+
res.end(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
workspaceId: lastWorkspaceId,
|
|
33
|
+
sopRules: [
|
|
34
|
+
{ id: 'rule_xyz', toolPattern: 'Bash', action: 'block', reason: 'Blocked in test' }
|
|
35
|
+
],
|
|
36
|
+
dlpPatterns: [],
|
|
37
|
+
interventionMode: 'BLOCK'
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
} else if (url.pathname === '/api/v1/sop/rules') {
|
|
41
|
+
lastWorkspaceId = url.searchParams.get('workspaceId')
|
|
42
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
43
|
+
res.end(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
rules: [
|
|
46
|
+
{ id: 'rule_xyz', toolPattern: 'Bash', action: 'block', reason: 'Blocked in test' }
|
|
47
|
+
]
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
} else if (url.pathname === '/api/v1/hook-events') {
|
|
51
|
+
let body = ''
|
|
52
|
+
req.on('data', c => body += c)
|
|
53
|
+
req.on('end', () => {
|
|
54
|
+
try {
|
|
55
|
+
telemetryEvents.push(JSON.parse(body))
|
|
56
|
+
} catch {}
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
58
|
+
res.end(JSON.stringify({ ok: true }))
|
|
59
|
+
})
|
|
60
|
+
} else {
|
|
61
|
+
res.writeHead(404)
|
|
62
|
+
res.end()
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await new Promise<void>((resolve) => {
|
|
67
|
+
mockCp.listen(0, '127.0.0.1', () => {
|
|
68
|
+
const addr = mockCp.address() as net.AddressInfo | null
|
|
69
|
+
cpPort = addr?.port ?? 3001
|
|
70
|
+
process.env['CONTROL_PLANE_URL'] = `http://127.0.0.1:${cpPort}`
|
|
71
|
+
process.env['INTUTIC_API_KEY'] = 'test-api-key'
|
|
72
|
+
resolve()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Start daemon socket server
|
|
77
|
+
server = createSocketServer()
|
|
78
|
+
await new Promise<void>((resolve) => server.listen(socketPath, () => resolve()))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
83
|
+
await new Promise<void>((resolve) => mockCp.close(() => resolve()))
|
|
84
|
+
await fs.rm(socketPath, { force: true })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('client callDaemonSocket requests policy.get and gets cached policy', async () => {
|
|
88
|
+
const res = await callDaemonSocket('policy.get', { workspaceId: 'ws_integration' })
|
|
89
|
+
expect(res).toBeDefined()
|
|
90
|
+
expect(res.workspaceId).toBe('ws_integration')
|
|
91
|
+
expect(res.sopRules).toHaveLength(1)
|
|
92
|
+
expect(res.sopRules[0].id).toBe('rule_xyz')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('PolicyClient uses daemon when mcpProxyMode is daemon', async () => {
|
|
96
|
+
const client = new PolicyClient(`http://127.0.0.1:${cpPort}`, 'test-api-key', 'ws_integration', 60000, 'daemon')
|
|
97
|
+
await client.refresh()
|
|
98
|
+
const rules = client.getRules()
|
|
99
|
+
expect(rules).toHaveLength(1)
|
|
100
|
+
expect(rules[0].id).toBe('rule_xyz')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('GovernanceEmitter enqueues telemetry to daemon', async () => {
|
|
104
|
+
const emitter = new GovernanceEmitter(`http://127.0.0.1:${cpPort}`, 'test-api-key', 'dummy-path.jsonl', 'ws_integration', 'daemon')
|
|
105
|
+
emitter.emit('tool_blocked', 'Bash', { args: ['rm -rf'] }, 'Blocked in test')
|
|
106
|
+
|
|
107
|
+
const res = await callDaemonSocket('proxy.health_check', {})
|
|
108
|
+
expect(res.status).toBe('ok')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shim client falls back to direct control plane requests if daemon socket is offline', async () => {
|
|
112
|
+
// Set socket path to invalid location
|
|
113
|
+
process.env['MCP_DAEMON_SOCKET'] = path.join(os.tmpdir(), `nonexistent-${Date.now()}.sock`)
|
|
114
|
+
|
|
115
|
+
const client = new PolicyClient(`http://127.0.0.1:${cpPort}`, 'test-api-key', 'ws_fallback', 60000, 'daemon')
|
|
116
|
+
await client.refresh()
|
|
117
|
+
const rules = client.getRules()
|
|
118
|
+
expect(rules).toHaveLength(1)
|
|
119
|
+
expect(rules[0].id).toBe('rule_xyz')
|
|
120
|
+
expect(lastWorkspaceId).toBe('ws_fallback') // hit CP directly
|
|
121
|
+
|
|
122
|
+
// Restore
|
|
123
|
+
process.env['MCP_DAEMON_SOCKET'] = socketPath
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
})
|