@mcpose/audit 2.0.3 → 3.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/LICENSE +21 -0
- package/README.md +440 -28
- package/dist/chain.d.ts +49 -6
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +227 -28
- package/dist/chain.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +3 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +499 -85
- package/dist/middleware.js.map +1 -1
- package/dist/sensitivity.d.ts +11 -2
- package/dist/sensitivity.d.ts.map +1 -1
- package/dist/sensitivity.js +18 -2
- package/dist/sensitivity.js.map +1 -1
- package/dist/signingKey.d.ts +8 -0
- package/dist/signingKey.d.ts.map +1 -1
- package/dist/signingKey.js +21 -4
- package/dist/signingKey.js.map +1 -1
- package/dist/subjectKeyStore.d.ts +17 -0
- package/dist/subjectKeyStore.d.ts.map +1 -0
- package/dist/subjectKeyStore.js +37 -0
- package/dist/subjectKeyStore.js.map +1 -0
- package/dist/types.d.ts +170 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +66 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -15
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +433 -0
- package/src/__tests__/erasable.test.ts +499 -0
- package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
- package/src/__tests__/middleware.http.test.ts +172 -0
- package/src/__tests__/middleware.rejection.test.ts +183 -0
- package/src/__tests__/middleware.robustness.test.ts +809 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/middleware.unusableResult.test.ts +141 -0
- package/src/__tests__/properties.test.ts +764 -0
- package/src/__tests__/sensitivity.test.ts +72 -0
- package/src/__tests__/signingKey.test.ts +76 -0
- package/src/chain.ts +317 -0
- package/src/index.ts +31 -0
- package/src/middleware.ts +781 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +287 -0
- package/src/verify.ts +92 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type * as http from 'node:http';
|
|
3
|
+
import { startHttpProxy } from 'mcpose';
|
|
4
|
+
import type { LocalTool } from 'mcpose';
|
|
5
|
+
import { createMockBackendClient } from 'mcpose/testing';
|
|
6
|
+
import { createAuditMiddleware } from '../middleware.js';
|
|
7
|
+
import { createDefaultSigningKeyProvider } from '../signingKey.js';
|
|
8
|
+
import { verifyAuditChain } from '../verify.js';
|
|
9
|
+
import type { AuditEvent, ReplayManifest } from '../types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* End-to-end regression for #168 over real loopback HTTP: a session torn
|
|
13
|
+
* down (client DELETE or TTL expiry) while a tool call is still inside its
|
|
14
|
+
* handler must still seal that call into the manifest, after any events
|
|
15
|
+
* the session already recorded.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MCP_HEADERS = {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
Accept: 'application/json, text/event-stream',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function deferred<T = void>() {
|
|
24
|
+
let resolve!: (v: T) => void;
|
|
25
|
+
const promise = new Promise<T>((r) => (resolve = r));
|
|
26
|
+
return { promise, resolve };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rpc(id: number, method: string, params: unknown): string {
|
|
30
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function port(server: http.Server): number {
|
|
34
|
+
const addr = server.address();
|
|
35
|
+
if (!addr || typeof addr === 'string') throw new Error('no port');
|
|
36
|
+
return addr.port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function harness(sessionTtlMs: number) {
|
|
40
|
+
const events: AuditEvent[] = [];
|
|
41
|
+
const manifests: Promise<ReplayManifest | undefined>[] = [];
|
|
42
|
+
const closed = deferred<string>();
|
|
43
|
+
const entered = deferred();
|
|
44
|
+
const release = deferred();
|
|
45
|
+
const audit = createAuditMiddleware({
|
|
46
|
+
signingKey: createDefaultSigningKeyProvider('test-secret'),
|
|
47
|
+
sensitivityResolver: () => 'low',
|
|
48
|
+
onEvent: (e) => void events.push(e),
|
|
49
|
+
});
|
|
50
|
+
const slow: LocalTool = {
|
|
51
|
+
tool: { name: 'slow', inputSchema: { type: 'object' } },
|
|
52
|
+
handler: async (params) => {
|
|
53
|
+
if (params.arguments?.['block'] === true) {
|
|
54
|
+
entered.resolve();
|
|
55
|
+
await release.promise;
|
|
56
|
+
}
|
|
57
|
+
return { content: [] };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const server = await startHttpProxy(
|
|
61
|
+
createMockBackendClient(),
|
|
62
|
+
{
|
|
63
|
+
name: 'audit-http-test',
|
|
64
|
+
localTools: [slow],
|
|
65
|
+
toolMiddleware: [audit.middleware],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
port: 0,
|
|
69
|
+
path: '/mcp',
|
|
70
|
+
sessionTtlMs,
|
|
71
|
+
onSessionClosed: (id) => {
|
|
72
|
+
manifests.push(audit.closeSession(id));
|
|
73
|
+
closed.resolve(id);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
const baseUrl = `http://localhost:${port(server)}/mcp`;
|
|
78
|
+
const init = await fetch(baseUrl, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: MCP_HEADERS,
|
|
81
|
+
body: rpc(1, 'initialize', {
|
|
82
|
+
protocolVersion: '2024-11-05',
|
|
83
|
+
capabilities: {},
|
|
84
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
await init.text();
|
|
88
|
+
const sessionId = init.headers.get('mcp-session-id')!;
|
|
89
|
+
expect(sessionId).toBeTruthy();
|
|
90
|
+
const headers = { ...MCP_HEADERS, 'mcp-session-id': sessionId };
|
|
91
|
+
const call = (id: number, block: boolean) =>
|
|
92
|
+
fetch(baseUrl, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers,
|
|
95
|
+
body: rpc(id, 'tools/call', { name: 'slow', arguments: { block } }),
|
|
96
|
+
})
|
|
97
|
+
.then((r) => r.text())
|
|
98
|
+
.catch(() => undefined);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
events,
|
|
102
|
+
manifests,
|
|
103
|
+
closed,
|
|
104
|
+
entered,
|
|
105
|
+
release,
|
|
106
|
+
sessionId,
|
|
107
|
+
headers,
|
|
108
|
+
baseUrl,
|
|
109
|
+
call,
|
|
110
|
+
close: () => new Promise<void>((r) => server.close(() => r())),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function expectSealed(h: Awaited<ReturnType<typeof harness>>) {
|
|
115
|
+
expect(h.manifests).toHaveLength(1);
|
|
116
|
+
const manifest = await h.manifests[0];
|
|
117
|
+
expect(manifest?.sessionId).toBe(h.sessionId);
|
|
118
|
+
expect(manifest?.eventCount).toBe(2);
|
|
119
|
+
expect(h.events.map((e) => e.replayManifestPosition)).toEqual([0, 1]);
|
|
120
|
+
expect(h.events.every((e) => e.sessionId === h.sessionId)).toBe(true);
|
|
121
|
+
expect(
|
|
122
|
+
await verifyAuditChain(
|
|
123
|
+
h.events,
|
|
124
|
+
createDefaultSigningKeyProvider('test-secret'),
|
|
125
|
+
),
|
|
126
|
+
).toEqual({ valid: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('audit middleware over HTTP — teardown during a blocked call (#168)', () => {
|
|
130
|
+
it('client DELETE during a blocked call still seals it into the manifest', async () => {
|
|
131
|
+
const h = await harness(60_000);
|
|
132
|
+
try {
|
|
133
|
+
await h.call(2, false);
|
|
134
|
+
expect(h.events).toHaveLength(1);
|
|
135
|
+
|
|
136
|
+
const blocked = h.call(3, true);
|
|
137
|
+
await h.entered.promise;
|
|
138
|
+
|
|
139
|
+
const del = await fetch(h.baseUrl, {
|
|
140
|
+
method: 'DELETE',
|
|
141
|
+
headers: h.headers,
|
|
142
|
+
});
|
|
143
|
+
expect(del.status).toBeLessThan(500);
|
|
144
|
+
expect(await h.closed.promise).toBe(h.sessionId);
|
|
145
|
+
|
|
146
|
+
h.release.resolve();
|
|
147
|
+
await blocked;
|
|
148
|
+
await expectSealed(h);
|
|
149
|
+
} finally {
|
|
150
|
+
h.release.resolve();
|
|
151
|
+
await h.close();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('TTL expiry during a blocked call still seals it into the manifest', async () => {
|
|
156
|
+
const h = await harness(150);
|
|
157
|
+
try {
|
|
158
|
+
await h.call(2, false);
|
|
159
|
+
const blocked = h.call(3, true);
|
|
160
|
+
await h.entered.promise;
|
|
161
|
+
|
|
162
|
+
expect(await h.closed.promise).toBe(h.sessionId);
|
|
163
|
+
|
|
164
|
+
h.release.resolve();
|
|
165
|
+
await blocked;
|
|
166
|
+
await expectSealed(h);
|
|
167
|
+
} finally {
|
|
168
|
+
h.release.resolve();
|
|
169
|
+
await h.close();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type * as http from 'node:http';
|
|
3
|
+
import { startHttpProxy } from 'mcpose';
|
|
4
|
+
import type { LocalTool } from 'mcpose';
|
|
5
|
+
import { createMockBackendClient } from 'mcpose/testing';
|
|
6
|
+
import { createAuditMiddleware } from '../middleware.js';
|
|
7
|
+
import { createDefaultSigningKeyProvider } from '../signingKey.js';
|
|
8
|
+
import { createSensitivityResolver } from '../sensitivity.js';
|
|
9
|
+
import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
|
|
10
|
+
import type { AuditEvent, AuditOptions, ReplayManifest } from '../types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A call that fails is only a `rejected` event when its error carries a
|
|
14
|
+
* member of the closed `RejectionReason` union.
|
|
15
|
+
*
|
|
16
|
+
* The failing call is a LOCAL tool handler (ADR-0007), so it runs inside the
|
|
17
|
+
* pipeline and the audit middleware observes it there, exactly as the tool
|
|
18
|
+
* call in the issue does. The error is shaped like an `McpError` from
|
|
19
|
+
* `rejectionMcpError()` without importing the SDK: the middleware
|
|
20
|
+
* duck-types `err.data.rejectionReason`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MESSAGE = 'ledger pool reset: no capacity';
|
|
24
|
+
|
|
25
|
+
const MCP_HEADERS = {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
Accept: 'application/json, text/event-stream',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function port(server: http.Server): number {
|
|
31
|
+
const addr = server.address();
|
|
32
|
+
if (!addr || typeof addr === 'string') throw new Error('no port');
|
|
33
|
+
return addr.port;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A local tool that always fails, carrying `reason` in the error's data. */
|
|
37
|
+
function failingLocalTool(reason: string): LocalTool {
|
|
38
|
+
return {
|
|
39
|
+
tool: { name: 'ledger_entry', inputSchema: { type: 'object' } },
|
|
40
|
+
handler: async () => {
|
|
41
|
+
throw Object.assign(new Error(MESSAGE), {
|
|
42
|
+
code: -32603,
|
|
43
|
+
data: { rejectionReason: reason },
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
50
|
+
|
|
51
|
+
async function harness(reason: string, overrides: Partial<AuditOptions> = {}) {
|
|
52
|
+
const events: AuditEvent[] = [];
|
|
53
|
+
const audit = createAuditMiddleware({
|
|
54
|
+
signingKey,
|
|
55
|
+
sensitivityResolver: createSensitivityResolver({ ledger_entry: 'low' }),
|
|
56
|
+
onEvent: (e) => void events.push(e),
|
|
57
|
+
...overrides,
|
|
58
|
+
});
|
|
59
|
+
const server = await startHttpProxy(
|
|
60
|
+
createMockBackendClient(),
|
|
61
|
+
{
|
|
62
|
+
name: 'audit-rejection-test',
|
|
63
|
+
localTools: [failingLocalTool(reason)],
|
|
64
|
+
toolMiddleware: [audit.middleware],
|
|
65
|
+
},
|
|
66
|
+
{ port: 0, path: '/mcp' },
|
|
67
|
+
);
|
|
68
|
+
const baseUrl = `http://localhost:${port(server)}/mcp`;
|
|
69
|
+
const init = await fetch(baseUrl, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: MCP_HEADERS,
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
jsonrpc: '2.0',
|
|
74
|
+
id: 1,
|
|
75
|
+
method: 'initialize',
|
|
76
|
+
params: {
|
|
77
|
+
protocolVersion: '2024-11-05',
|
|
78
|
+
capabilities: {},
|
|
79
|
+
clientInfo: { name: 'test', version: '0.0.1' },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
await init.text();
|
|
84
|
+
const sessionId = init.headers.get('mcp-session-id')!;
|
|
85
|
+
expect(sessionId).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
const call = await fetch(baseUrl, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { ...MCP_HEADERS, 'mcp-session-id': sessionId },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
jsonrpc: '2.0',
|
|
92
|
+
id: 2,
|
|
93
|
+
method: 'tools/call',
|
|
94
|
+
params: { name: 'ledger_entry', arguments: { amount: 10 } },
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
await call.text();
|
|
98
|
+
const manifest: ReplayManifest | undefined =
|
|
99
|
+
await audit.closeSession(sessionId);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
events,
|
|
103
|
+
manifest,
|
|
104
|
+
close: () => new Promise<void>((r) => server.close(() => r())),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('rejection classification — only a RejectionReason member is a rejection (#238)', () => {
|
|
109
|
+
it('records an error carrying a reason outside the union as an error, with the real message', async () => {
|
|
110
|
+
const h = await harness('LEDGER_POOL_RESET');
|
|
111
|
+
try {
|
|
112
|
+
expect(h.events).toHaveLength(1);
|
|
113
|
+
const event = h.events[0]!;
|
|
114
|
+
expect(event.outcome).toBe('error');
|
|
115
|
+
// The unvalidated string is not a covered field of this event: an
|
|
116
|
+
// omitted key keeps the preimage the event has always had
|
|
117
|
+
// (ADR-0012), and the classification that ADR-0004's `error` field
|
|
118
|
+
// exists for is restored.
|
|
119
|
+
expect(Object.hasOwn(event, 'rejectionReason')).toBe(false);
|
|
120
|
+
expect(event.error).toEqual({ name: 'Error', message: MESSAGE });
|
|
121
|
+
expect(event.tool).toBe('ledger_entry');
|
|
122
|
+
expect(await verifyAuditChain(h.events, signingKey)).toEqual({
|
|
123
|
+
valid: true,
|
|
124
|
+
});
|
|
125
|
+
expect(h.manifest?.eventCount).toBe(1);
|
|
126
|
+
expect(await verifyManifestSignature(h.manifest!, signingKey)).toBe(true);
|
|
127
|
+
} finally {
|
|
128
|
+
await h.close();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('records a reason inside the union as a rejection, unchanged', async () => {
|
|
133
|
+
const h = await harness('POLICY_DENIED');
|
|
134
|
+
try {
|
|
135
|
+
expect(h.events).toHaveLength(1);
|
|
136
|
+
const event = h.events[0]!;
|
|
137
|
+
expect(event.outcome).toBe('rejected');
|
|
138
|
+
expect(event.rejectionReason).toBe('POLICY_DENIED');
|
|
139
|
+
expect(Object.hasOwn(event, 'error')).toBe(false);
|
|
140
|
+
expect(await verifyAuditChain(h.events, signingKey)).toEqual({
|
|
141
|
+
valid: true,
|
|
142
|
+
});
|
|
143
|
+
} finally {
|
|
144
|
+
await h.close();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('records a reason that is a prototype key as an error, not a rejection', async () => {
|
|
149
|
+
const h = await harness('constructor');
|
|
150
|
+
try {
|
|
151
|
+
expect(h.events).toHaveLength(1);
|
|
152
|
+
expect(h.events[0]!.outcome).toBe('error');
|
|
153
|
+
expect(Object.hasOwn(h.events[0]!, 'rejectionReason')).toBe(false);
|
|
154
|
+
} finally {
|
|
155
|
+
await h.close();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('includeRejections: false still drops a rejection, and still records a non-member as an error', async () => {
|
|
160
|
+
const dropped = await harness('POLICY_DENIED', {
|
|
161
|
+
includeRejections: false,
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
expect(dropped.events).toEqual([]);
|
|
165
|
+
} finally {
|
|
166
|
+
await dropped.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const recorded = await harness('LEDGER_POOL_RESET', {
|
|
170
|
+
includeRejections: false,
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
expect(recorded.events).toHaveLength(1);
|
|
174
|
+
expect(recorded.events[0]!.outcome).toBe('error');
|
|
175
|
+
expect(recorded.events[0]!.error).toEqual({
|
|
176
|
+
name: 'Error',
|
|
177
|
+
message: MESSAGE,
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
await recorded.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|