@permission-protocol/mcp-guard 0.1.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.
Files changed (59) hide show
  1. package/README.md +224 -0
  2. package/assets/demo.gif +0 -0
  3. package/bin/mcp-guard +2 -0
  4. package/demo/_demo-scenario.sh +43 -0
  5. package/dist/example/server.d.ts +1 -0
  6. package/dist/example/server.js +54 -0
  7. package/dist/example/server.js.map +1 -0
  8. package/dist/src/approval-server.d.ts +4 -0
  9. package/dist/src/approval-server.js +128 -0
  10. package/dist/src/approval-server.js.map +1 -0
  11. package/dist/src/approval-ui.d.ts +1 -0
  12. package/dist/src/approval-ui.js +158 -0
  13. package/dist/src/approval-ui.js.map +1 -0
  14. package/dist/src/cli.d.ts +1 -0
  15. package/dist/src/cli.js +82 -0
  16. package/dist/src/cli.js.map +1 -0
  17. package/dist/src/config.d.ts +13 -0
  18. package/dist/src/config.js +48 -0
  19. package/dist/src/config.js.map +1 -0
  20. package/dist/src/engine.d.ts +7 -0
  21. package/dist/src/engine.js +25 -0
  22. package/dist/src/engine.js.map +1 -0
  23. package/dist/src/pending.d.ts +20 -0
  24. package/dist/src/pending.js +44 -0
  25. package/dist/src/pending.js.map +1 -0
  26. package/dist/src/proxy.d.ts +2 -0
  27. package/dist/src/proxy.js +147 -0
  28. package/dist/src/proxy.js.map +1 -0
  29. package/dist/src/receipt.d.ts +15 -0
  30. package/dist/src/receipt.js +34 -0
  31. package/dist/src/receipt.js.map +1 -0
  32. package/dist/src/tests/config.test.d.ts +1 -0
  33. package/dist/src/tests/config.test.js +63 -0
  34. package/dist/src/tests/config.test.js.map +1 -0
  35. package/dist/src/tests/engine.test.d.ts +1 -0
  36. package/dist/src/tests/engine.test.js +46 -0
  37. package/dist/src/tests/engine.test.js.map +1 -0
  38. package/dist/src/tests/receipt.test.d.ts +1 -0
  39. package/dist/src/tests/receipt.test.js +44 -0
  40. package/dist/src/tests/receipt.test.js.map +1 -0
  41. package/docs/index.html +771 -0
  42. package/example/README.md +38 -0
  43. package/example/pp.config.yaml +15 -0
  44. package/example/server.ts +52 -0
  45. package/example/test.sh +64 -0
  46. package/package.json +29 -0
  47. package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
  48. package/src/approval-server.ts +136 -0
  49. package/src/approval-ui.ts +154 -0
  50. package/src/cli.ts +84 -0
  51. package/src/config.ts +68 -0
  52. package/src/engine.ts +31 -0
  53. package/src/pending.ts +65 -0
  54. package/src/proxy.ts +173 -0
  55. package/src/receipt.ts +52 -0
  56. package/src/tests/config.test.ts +61 -0
  57. package/src/tests/engine.test.ts +47 -0
  58. package/src/tests/receipt.test.ts +44 -0
  59. package/tsconfig.json +16 -0
package/src/pending.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+
3
+ export interface PendingAction {
4
+ id: string;
5
+ timestamp: string;
6
+ tool_name: string;
7
+ tool_args: any;
8
+ agent_id: string;
9
+ rule_id: string;
10
+ status: 'pending' | 'approved' | 'denied';
11
+ /** The original JSON-RPC line to forward on approval */
12
+ originalLine: string;
13
+ /** The JSON-RPC request id for matching child responses */
14
+ jsonrpcId: string | number | null;
15
+ /** Resolver — call with the response line (or error JSON) to unblock the proxy */
16
+ resolve: (responseLine: string) => void;
17
+ }
18
+
19
+ const queue: Map<string, PendingAction> = new Map();
20
+
21
+ export function addPending(
22
+ toolName: string,
23
+ toolArgs: any,
24
+ agentId: string,
25
+ ruleId: string,
26
+ originalLine: string,
27
+ jsonrpcId: string | number | null,
28
+ resolve: (responseLine: string) => void,
29
+ ): PendingAction {
30
+ const action: PendingAction = {
31
+ id: uuidv4(),
32
+ timestamp: new Date().toISOString(),
33
+ tool_name: toolName,
34
+ tool_args: toolArgs,
35
+ agent_id: agentId,
36
+ rule_id: ruleId,
37
+ status: 'pending',
38
+ originalLine,
39
+ jsonrpcId,
40
+ resolve,
41
+ };
42
+ queue.set(action.id, action);
43
+ return action;
44
+ }
45
+
46
+ export function getPending(): PendingAction[] {
47
+ return Array.from(queue.values()).filter(a => a.status === 'pending');
48
+ }
49
+
50
+ export function getAction(id: string): PendingAction | undefined {
51
+ return queue.get(id);
52
+ }
53
+
54
+ export function resolveAction(id: string, status: 'approved' | 'denied'): PendingAction | undefined {
55
+ const action = queue.get(id);
56
+ if (!action || action.status !== 'pending') return undefined;
57
+ action.status = status;
58
+ return action;
59
+ }
60
+
61
+ export function recentResolved(limit: number = 50): PendingAction[] {
62
+ return Array.from(queue.values())
63
+ .filter(a => a.status !== 'pending')
64
+ .slice(-limit);
65
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import type { Config } from './config.js';
4
+ import { evaluate } from './engine.js';
5
+ import { createReceipt, emitReceipt } from './receipt.js';
6
+ import { addPending, getAction } from './pending.js';
7
+ import { startApprovalServer, setOnApprove, pushReceipt } from './approval-server.js';
8
+
9
+ interface JsonRpcRequest {
10
+ jsonrpc: '2.0';
11
+ id?: string | number | null;
12
+ method: string;
13
+ params?: any;
14
+ }
15
+
16
+ interface JsonRpcError {
17
+ jsonrpc: '2.0';
18
+ id: string | number | null;
19
+ error: {
20
+ code: number;
21
+ message: string;
22
+ };
23
+ }
24
+
25
+ export function startProxy(config: Config, agentId: string, serverCommand: string[], approvalPort?: number): void {
26
+ if (serverCommand.length === 0) {
27
+ process.stderr.write('[mcp-guard] Error: no server command provided after --\n');
28
+ process.exit(1);
29
+ }
30
+
31
+ const approvalEnabled = approvalPort !== undefined;
32
+
33
+ // Start approval server if enabled
34
+ if (approvalEnabled) {
35
+ startApprovalServer(approvalPort);
36
+ }
37
+
38
+ const child: ChildProcess = spawn(serverCommand[0], serverCommand.slice(1), {
39
+ stdio: ['pipe', 'pipe', 'inherit'],
40
+ });
41
+
42
+ child.on('error', (err) => {
43
+ process.stderr.write(`[mcp-guard] Failed to start server: ${err.message}\n`);
44
+ process.exit(1);
45
+ });
46
+
47
+ child.on('exit', (code) => {
48
+ process.stderr.write(`[mcp-guard] Server exited with code ${code}\n`);
49
+ process.exit(code ?? 1);
50
+ });
51
+
52
+ // Track pending approval responses waiting for child stdout
53
+ const pendingChildResponses: Map<string | number, (line: string) => void> = new Map();
54
+
55
+ // Forward child stdout → our stdout (or to pending resolver)
56
+ const childOut = createInterface({ input: child.stdout! });
57
+ childOut.on('line', (line) => {
58
+ // Check if this is a response to a forwarded approval
59
+ try {
60
+ const parsed = JSON.parse(line);
61
+ const id = parsed.id;
62
+ if (id != null && pendingChildResponses.has(id)) {
63
+ const resolver = pendingChildResponses.get(id)!;
64
+ pendingChildResponses.delete(id);
65
+ resolver(line);
66
+ return;
67
+ }
68
+ } catch {
69
+ // not JSON, pass through
70
+ }
71
+ process.stdout.write(line + '\n');
72
+ });
73
+
74
+ // Set up approval callback — when UI approves, forward original request to child
75
+ if (approvalEnabled) {
76
+ setOnApprove((actionId: string) => {
77
+ const action = getAction(actionId);
78
+ if (!action) return;
79
+ // Register a listener for the child's response
80
+ if (action.jsonrpcId != null) {
81
+ pendingChildResponses.set(action.jsonrpcId, (responseLine: string) => {
82
+ action.resolve(responseLine);
83
+ });
84
+ }
85
+ // Forward the original request to the child server
86
+ child.stdin!.write(action.originalLine + '\n');
87
+ });
88
+ }
89
+
90
+ // Read our stdin, inspect, and forward or reject
91
+ const stdinRL = createInterface({ input: process.stdin });
92
+ stdinRL.on('line', (line) => {
93
+ if (!line.trim()) return;
94
+
95
+ let msg: JsonRpcRequest;
96
+ try {
97
+ msg = JSON.parse(line);
98
+ } catch {
99
+ // Not valid JSON — forward as-is
100
+ child.stdin!.write(line + '\n');
101
+ return;
102
+ }
103
+
104
+ if (msg.method === 'tools/call') {
105
+ const toolName: string = msg.params?.name ?? 'unknown';
106
+ const toolArgs: Record<string, unknown> | undefined = msg.params?.arguments;
107
+ const decision = evaluate(toolName, toolArgs, config);
108
+ const serverName = serverCommand.join(' ');
109
+ const receipt = createReceipt(agentId, toolName, decision, msg.params, serverName, config.mode);
110
+ emitReceipt(receipt);
111
+ if (approvalEnabled) pushReceipt(receipt);
112
+
113
+ // In observe mode, always forward (log only, don't block)
114
+ if (config.mode === 'observe') {
115
+ if (decision.decision !== 'allowed') {
116
+ process.stderr.write(`[mcp-guard] OBSERVE: would ${decision.decision === 'blocked' ? 'block' : 'hold'} "${toolName}" but forwarding (observe mode)\n`);
117
+ }
118
+ child.stdin!.write(line + '\n');
119
+ } else if (decision.decision === 'allowed') {
120
+ child.stdin!.write(line + '\n');
121
+ } else if (decision.decision === 'blocked') {
122
+ const errResp: JsonRpcError = {
123
+ jsonrpc: '2.0',
124
+ id: msg.id ?? null,
125
+ error: {
126
+ code: -32001,
127
+ message: `Blocked: ${decision.reason}`,
128
+ },
129
+ };
130
+ process.stdout.write(JSON.stringify(errResp) + '\n');
131
+ } else {
132
+ // held_for_approval
133
+ if (approvalEnabled) {
134
+ // Hold the response — add to pending queue
135
+ const pending = addPending(
136
+ toolName,
137
+ toolArgs,
138
+ agentId,
139
+ decision.rule_id ?? 'unknown',
140
+ line,
141
+ msg.id ?? null,
142
+ (responseLine: string) => {
143
+ process.stdout.write(responseLine + '\n');
144
+ },
145
+ );
146
+ process.stderr.write(`[mcp-guard] Held "${toolName}" for approval (${pending.id}) — approve at UI\n`);
147
+ } else {
148
+ // No approval server — return error immediately (original behavior)
149
+ const errResp: JsonRpcError = {
150
+ jsonrpc: '2.0',
151
+ id: msg.id ?? null,
152
+ error: {
153
+ code: -32002,
154
+ message: `Held for approval: ${decision.reason}`,
155
+ },
156
+ };
157
+ process.stdout.write(JSON.stringify(errResp) + '\n');
158
+ }
159
+ }
160
+ } else {
161
+ // Pass through all non-tools/call methods
162
+ child.stdin!.write(line + '\n');
163
+ }
164
+ });
165
+
166
+ stdinRL.on('close', () => {
167
+ child.stdin!.end();
168
+ });
169
+
170
+ // Handle signals gracefully
171
+ process.on('SIGINT', () => child.kill('SIGINT'));
172
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
173
+ }
package/src/receipt.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync } from 'node:fs';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import type { Decision } from './engine.js';
5
+
6
+ export interface Receipt {
7
+ receipt_id: string;
8
+ timestamp: string;
9
+ agent_id: string;
10
+ tool_name: string;
11
+ decision: Decision['decision'];
12
+ reason: string;
13
+ rule_id: string | null;
14
+ request_payload_hash: string;
15
+ target_server: string;
16
+ mode: 'enforce' | 'observe';
17
+ }
18
+
19
+ export function createReceipt(
20
+ agentId: string,
21
+ toolName: string,
22
+ decision: Decision,
23
+ requestPayload: unknown,
24
+ targetServer: string = 'unknown',
25
+ mode: 'enforce' | 'observe' = 'enforce',
26
+ ): Receipt {
27
+ const payloadStr = JSON.stringify(requestPayload ?? {});
28
+ const hash = createHash('sha256').update(payloadStr).digest('hex');
29
+
30
+ return {
31
+ receipt_id: uuidv4(),
32
+ timestamp: new Date().toISOString(),
33
+ agent_id: agentId,
34
+ tool_name: toolName,
35
+ decision: decision.decision,
36
+ reason: decision.reason,
37
+ rule_id: decision.rule_id,
38
+ request_payload_hash: hash,
39
+ target_server: targetServer,
40
+ mode,
41
+ };
42
+ }
43
+
44
+ export function emitReceipt(receipt: Receipt, receiptsPath: string = 'pp-receipts.jsonl'): void {
45
+ const json = JSON.stringify(receipt);
46
+ process.stderr.write(`[mcp-guard] receipt: ${json}\n`);
47
+ try {
48
+ appendFileSync(receiptsPath, json + '\n');
49
+ } catch {
50
+ // Best effort — don't crash the proxy if file write fails
51
+ }
52
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { loadConfig } from '../config.js';
7
+
8
+ describe('Config Parser', () => {
9
+ it('parses a valid config', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'mcp-guard-test-'));
11
+ const path = join(dir, 'config.yaml');
12
+ writeFileSync(path, `
13
+ default_action: allow
14
+ rules:
15
+ - id: block-delete
16
+ tool: delete_user_data
17
+ action: block
18
+ - id: hold-deploy
19
+ tool: deploy_production
20
+ action: require_approval
21
+ `);
22
+ const config = loadConfig(path);
23
+ assert.equal(config.default_action, 'allow');
24
+ assert.equal(config.rules.length, 2);
25
+ assert.equal(config.rules[0].id, 'block-delete');
26
+ assert.equal(config.rules[0].tool, 'delete_user_data');
27
+ assert.equal(config.rules[0].action, 'block');
28
+ assert.equal(config.rules[1].action, 'require_approval');
29
+ assert.equal(config.mode, 'enforce'); // default
30
+ unlinkSync(path);
31
+ });
32
+
33
+ it('rejects invalid config (bad action)', () => {
34
+ const dir = mkdtempSync(join(tmpdir(), 'mcp-guard-test-'));
35
+ const path = join(dir, 'config.yaml');
36
+ writeFileSync(path, `
37
+ default_action: allow
38
+ rules:
39
+ - id: bad
40
+ tool: foo
41
+ action: nuke_it
42
+ `);
43
+ assert.throws(() => loadConfig(path), /invalid action/i);
44
+ unlinkSync(path);
45
+ });
46
+
47
+ it('throws on missing file', () => {
48
+ assert.throws(() => loadConfig('/tmp/nonexistent-mcp-guard-config.yaml'), /Failed to read config/);
49
+ });
50
+
51
+ it('rejects config with invalid default_action', () => {
52
+ const dir = mkdtempSync(join(tmpdir(), 'mcp-guard-test-'));
53
+ const path = join(dir, 'config.yaml');
54
+ writeFileSync(path, `
55
+ default_action: yolo
56
+ rules: []
57
+ `);
58
+ assert.throws(() => loadConfig(path), /Invalid default_action/);
59
+ unlinkSync(path);
60
+ });
61
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { evaluate } from '../engine.js';
4
+ import type { Config } from '../config.js';
5
+
6
+ describe('Decision Engine', () => {
7
+ const config: Config = {
8
+ default_action: 'allow',
9
+ mode: 'enforce',
10
+ rules: [
11
+ { id: 'block-delete', tool: 'delete_user_data', action: 'block' },
12
+ { id: 'hold-deploy', tool: 'deploy_production', action: 'require_approval' },
13
+ { id: 'allow-email', tool: 'send_email', action: 'allow' },
14
+ ],
15
+ };
16
+
17
+ it('matches a block rule', () => {
18
+ const d = evaluate('delete_user_data', undefined, config);
19
+ assert.equal(d.decision, 'blocked');
20
+ assert.equal(d.rule_id, 'block-delete');
21
+ });
22
+
23
+ it('matches a require_approval rule', () => {
24
+ const d = evaluate('deploy_production', undefined, config);
25
+ assert.equal(d.decision, 'held_for_approval');
26
+ assert.equal(d.rule_id, 'hold-deploy');
27
+ });
28
+
29
+ it('matches an allow rule', () => {
30
+ const d = evaluate('send_email', undefined, config);
31
+ assert.equal(d.decision, 'allowed');
32
+ assert.equal(d.rule_id, 'allow-email');
33
+ });
34
+
35
+ it('falls back to default_action: allow', () => {
36
+ const d = evaluate('unknown_tool', undefined, config);
37
+ assert.equal(d.decision, 'allowed');
38
+ assert.equal(d.rule_id, null);
39
+ });
40
+
41
+ it('falls back to default_action: block', () => {
42
+ const blockConfig: Config = { default_action: 'block', mode: 'enforce', rules: [] };
43
+ const d = evaluate('any_tool', undefined, blockConfig);
44
+ assert.equal(d.decision, 'blocked');
45
+ assert.equal(d.rule_id, null);
46
+ });
47
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createReceipt } from '../receipt.js';
4
+ import type { Decision } from '../engine.js';
5
+
6
+ describe('Receipt Emitter', () => {
7
+ it('generates a valid receipt with all fields', () => {
8
+ const decision: Decision = {
9
+ decision: 'blocked',
10
+ rule_id: 'block-delete',
11
+ reason: 'Matched rule "block-delete"',
12
+ };
13
+
14
+ const receipt = createReceipt('agent-1', 'delete_user_data', decision, { name: 'delete_user_data', arguments: {} });
15
+
16
+ assert.ok(receipt.receipt_id, 'has receipt_id');
17
+ assert.match(receipt.receipt_id, /^[0-9a-f-]{36}$/, 'receipt_id is UUID');
18
+ assert.ok(receipt.timestamp, 'has timestamp');
19
+ assert.doesNotThrow(() => new Date(receipt.timestamp), 'timestamp is valid ISO');
20
+ assert.equal(receipt.agent_id, 'agent-1');
21
+ assert.equal(receipt.tool_name, 'delete_user_data');
22
+ assert.equal(receipt.decision, 'blocked');
23
+ assert.equal(receipt.reason, 'Matched rule "block-delete"');
24
+ assert.equal(receipt.rule_id, 'block-delete');
25
+ assert.equal(typeof receipt.request_payload_hash, 'string');
26
+ assert.equal(receipt.request_payload_hash.length, 64, 'SHA-256 hex is 64 chars');
27
+ assert.equal(receipt.target_server, 'unknown');
28
+ assert.equal(receipt.mode, 'enforce');
29
+ });
30
+
31
+ it('hashes different payloads differently', () => {
32
+ const decision: Decision = { decision: 'allowed', rule_id: null, reason: 'test' };
33
+ const r1 = createReceipt('a', 'tool', decision, { foo: 1 });
34
+ const r2 = createReceipt('a', 'tool', decision, { foo: 2 });
35
+ assert.notEqual(r1.request_payload_hash, r2.request_payload_hash);
36
+ });
37
+
38
+ it('generates unique receipt IDs', () => {
39
+ const decision: Decision = { decision: 'allowed', rule_id: null, reason: 'test' };
40
+ const r1 = createReceipt('a', 'tool', decision, {});
41
+ const r2 = createReceipt('a', 'tool', decision, {});
42
+ assert.notEqual(r1.receipt_id, r2.receipt_id);
43
+ });
44
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": ".",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "sourceMap": true
13
+ },
14
+ "include": ["src/**/*.ts", "example/server.ts"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }