@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.
- package/README.md +224 -0
- package/assets/demo.gif +0 -0
- package/bin/mcp-guard +2 -0
- package/demo/_demo-scenario.sh +43 -0
- package/dist/example/server.d.ts +1 -0
- package/dist/example/server.js +54 -0
- package/dist/example/server.js.map +1 -0
- package/dist/src/approval-server.d.ts +4 -0
- package/dist/src/approval-server.js +128 -0
- package/dist/src/approval-server.js.map +1 -0
- package/dist/src/approval-ui.d.ts +1 -0
- package/dist/src/approval-ui.js +158 -0
- package/dist/src/approval-ui.js.map +1 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +82 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/config.js +48 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/engine.d.ts +7 -0
- package/dist/src/engine.js +25 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/pending.d.ts +20 -0
- package/dist/src/pending.js +44 -0
- package/dist/src/pending.js.map +1 -0
- package/dist/src/proxy.d.ts +2 -0
- package/dist/src/proxy.js +147 -0
- package/dist/src/proxy.js.map +1 -0
- package/dist/src/receipt.d.ts +15 -0
- package/dist/src/receipt.js +34 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/tests/config.test.d.ts +1 -0
- package/dist/src/tests/config.test.js +63 -0
- package/dist/src/tests/config.test.js.map +1 -0
- package/dist/src/tests/engine.test.d.ts +1 -0
- package/dist/src/tests/engine.test.js +46 -0
- package/dist/src/tests/engine.test.js.map +1 -0
- package/dist/src/tests/receipt.test.d.ts +1 -0
- package/dist/src/tests/receipt.test.js +44 -0
- package/dist/src/tests/receipt.test.js.map +1 -0
- package/docs/index.html +771 -0
- package/example/README.md +38 -0
- package/example/pp.config.yaml +15 -0
- package/example/server.ts +52 -0
- package/example/test.sh +64 -0
- package/package.json +29 -0
- package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
- package/src/approval-server.ts +136 -0
- package/src/approval-ui.ts +154 -0
- package/src/cli.ts +84 -0
- package/src/config.ts +68 -0
- package/src/engine.ts +31 -0
- package/src/pending.ts +65 -0
- package/src/proxy.ts +173 -0
- package/src/receipt.ts +52 -0
- package/src/tests/config.test.ts +61 -0
- package/src/tests/engine.test.ts +47 -0
- package/src/tests/receipt.test.ts +44 -0
- 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
|
+
}
|