@7h3/protocol 0.1.2 → 0.4.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/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +323 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +385 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner.png +0 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +79 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- package/sdk/go/go.mod +3 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/webhook.go +136 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- package/src/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -0
- package/src/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +171 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +163 -0
- package/src/index.ts +32 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +413 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +141 -0
- package/src/replayStores.ts +82 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- package/src/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +100 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocol.d.ts +0 -66
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- package/runtimePolicyPresets.d.ts +0 -11
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import readline from 'node:readline'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import type { Readable, Writable } from 'node:stream'
|
|
4
|
+
import type { WireEnvelope } from './protocolTransport'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transport adapters for the MCP hardening wrapper.
|
|
8
|
+
*
|
|
9
|
+
* The wrapper (`wrapMcpServer` / `wrapMcpClient`) is transport-agnostic: it
|
|
10
|
+
* operates on `WireEnvelope`s. These adapters connect it to real transports —
|
|
11
|
+
* newline-delimited stdio and HTTP — so a secured server is deployable as-is.
|
|
12
|
+
*
|
|
13
|
+
* Note: the stdio adapter frames one envelope per line and therefore supports
|
|
14
|
+
* the string wire formats (`json`, `compact`). For `binary`, use HTTP.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type WireHandler = (raw: WireEnvelope) => Promise<WireEnvelope>
|
|
18
|
+
|
|
19
|
+
function assertStringWire(raw: WireEnvelope): string {
|
|
20
|
+
if (typeof raw !== 'string') {
|
|
21
|
+
throw new Error('stdio adapter supports json/compact (string) wire formats; use the HTTP adapter for binary')
|
|
22
|
+
}
|
|
23
|
+
return raw
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StdioServerHandle {
|
|
27
|
+
close(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Serves a wrapped MCP handler over newline-delimited streams (default
|
|
32
|
+
* `process.stdin` / `process.stdout`). Lines are processed sequentially so
|
|
33
|
+
* responses are emitted in request order.
|
|
34
|
+
*/
|
|
35
|
+
export function serveMcpOverStdio(
|
|
36
|
+
handle: WireHandler,
|
|
37
|
+
options: { input?: Readable; output?: Writable; onError?: (error: unknown) => void } = {},
|
|
38
|
+
): StdioServerHandle {
|
|
39
|
+
const input = options.input ?? process.stdin
|
|
40
|
+
const output = options.output ?? process.stdout
|
|
41
|
+
const rl = readline.createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY })
|
|
42
|
+
|
|
43
|
+
let chain: Promise<void> = Promise.resolve()
|
|
44
|
+
const onLine = (line: string): void => {
|
|
45
|
+
const raw = line.trim()
|
|
46
|
+
if (!raw) return
|
|
47
|
+
chain = chain.then(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const response = await handle(raw)
|
|
50
|
+
output.write(`${assertStringWire(response)}\n`)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
options.onError?.(error)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
rl.on('line', onLine)
|
|
58
|
+
return {
|
|
59
|
+
close(): void {
|
|
60
|
+
rl.off('line', onLine)
|
|
61
|
+
rl.close()
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface StdioClientHandle {
|
|
67
|
+
send: WireHandler
|
|
68
|
+
close(): void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a stdio client `send` function for `wrapMcpClient`. Requests and
|
|
73
|
+
* responses are correlated by order over the single pipe (one envelope per line).
|
|
74
|
+
*/
|
|
75
|
+
export function createStdioMcpClient(options: { input: Readable; output: Writable }): StdioClientHandle {
|
|
76
|
+
const rl = readline.createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY })
|
|
77
|
+
const pending: Array<(line: string) => void> = []
|
|
78
|
+
|
|
79
|
+
const onLine = (line: string): void => {
|
|
80
|
+
const raw = line.trim()
|
|
81
|
+
if (!raw) return
|
|
82
|
+
const resolve = pending.shift()
|
|
83
|
+
if (resolve) resolve(raw)
|
|
84
|
+
}
|
|
85
|
+
rl.on('line', onLine)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
send(raw: WireEnvelope): Promise<WireEnvelope> {
|
|
89
|
+
const line = assertStringWire(raw)
|
|
90
|
+
return new Promise<WireEnvelope>((resolve) => {
|
|
91
|
+
pending.push(resolve)
|
|
92
|
+
options.output.write(`${line}\n`)
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
close(): void {
|
|
96
|
+
rl.off('line', onLine)
|
|
97
|
+
rl.close()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates a `node:http`-compatible request handler that runs the wrapped MCP
|
|
104
|
+
* handler. The request body is the wire envelope; the response body is the
|
|
105
|
+
* signed reply envelope.
|
|
106
|
+
*/
|
|
107
|
+
export function createHttpMcpHandler(
|
|
108
|
+
handle: WireHandler,
|
|
109
|
+
options: { binary?: boolean } = {},
|
|
110
|
+
): (req: IncomingMessage, res: ServerResponse) => void {
|
|
111
|
+
return (req, res) => {
|
|
112
|
+
const chunks: Buffer[] = []
|
|
113
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
114
|
+
req.on('error', () => {
|
|
115
|
+
res.writeHead(400)
|
|
116
|
+
res.end()
|
|
117
|
+
})
|
|
118
|
+
req.on('end', () => {
|
|
119
|
+
void (async () => {
|
|
120
|
+
try {
|
|
121
|
+
const buffer = Buffer.concat(chunks)
|
|
122
|
+
const raw: WireEnvelope = options.binary ? new Uint8Array(buffer) : buffer.toString('utf8')
|
|
123
|
+
const response = await handle(raw)
|
|
124
|
+
if (typeof response === 'string') {
|
|
125
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
126
|
+
res.end(response)
|
|
127
|
+
} else {
|
|
128
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' })
|
|
129
|
+
res.end(Buffer.from(response))
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
133
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: error instanceof Error ? error.message : 'internal error' } }))
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface HttpClientHandle {
|
|
141
|
+
send: WireHandler
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Creates an HTTP client `send` function for `wrapMcpClient`. POSTs the request
|
|
146
|
+
* envelope to `url` and returns the response envelope. Uses the global `fetch`
|
|
147
|
+
* by default; inject `fetchImpl` for tests or custom agents.
|
|
148
|
+
*/
|
|
149
|
+
export function createHttpMcpClient(options: {
|
|
150
|
+
url: string
|
|
151
|
+
fetchImpl?: typeof fetch
|
|
152
|
+
binary?: boolean
|
|
153
|
+
headers?: Record<string, string>
|
|
154
|
+
}): HttpClientHandle {
|
|
155
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
156
|
+
const contentType = options.binary ? 'application/octet-stream' : 'application/json'
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
async send(raw: WireEnvelope): Promise<WireEnvelope> {
|
|
160
|
+
const response = await doFetch(options.url, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'content-type': contentType, ...options.headers },
|
|
163
|
+
body: typeof raw === 'string' ? raw : Buffer.from(raw),
|
|
164
|
+
})
|
|
165
|
+
if (options.binary) return new Uint8Array(await response.arrayBuffer())
|
|
166
|
+
return response.text()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createEnvelope, signEnvelopeHmac } from './protocol'
|
|
3
|
+
import { InMemoryReplayCache } from './protocolReplay'
|
|
4
|
+
import { encodeEnvelope, type WireEnvelope } from './protocolTransport'
|
|
5
|
+
import {
|
|
6
|
+
createMcpClientCodec,
|
|
7
|
+
wrapMcpClient,
|
|
8
|
+
wrapMcpServer,
|
|
9
|
+
type JsonRpcRequest,
|
|
10
|
+
type McpSecurityOptions,
|
|
11
|
+
} from './mcpWrapper'
|
|
12
|
+
|
|
13
|
+
const SECRET = 'mcp-shared-secret'
|
|
14
|
+
|
|
15
|
+
function serverOptions(extra: Partial<McpSecurityOptions> = {}): McpSecurityOptions {
|
|
16
|
+
return {
|
|
17
|
+
selfAgentId: 'agent.server',
|
|
18
|
+
sign: (envelope) => signEnvelopeHmac(envelope, SECRET, 'server-k1'),
|
|
19
|
+
receive: { secretResolver: async () => SECRET },
|
|
20
|
+
wireFormat: 'json',
|
|
21
|
+
...extra,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clientOptions(extra: Partial<McpSecurityOptions> = {}): McpSecurityOptions {
|
|
26
|
+
return {
|
|
27
|
+
selfAgentId: 'agent.client',
|
|
28
|
+
peerAgentId: 'agent.server',
|
|
29
|
+
sign: (envelope) => signEnvelopeHmac(envelope, SECRET, 'client-k1'),
|
|
30
|
+
receive: { secretResolver: async () => SECRET },
|
|
31
|
+
wireFormat: 'json',
|
|
32
|
+
...extra,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const echo: (req: JsonRpcRequest) => Promise<{ jsonrpc: '2.0'; id: JsonRpcRequest['id']; result: unknown }> = async (req) => ({
|
|
37
|
+
jsonrpc: '2.0',
|
|
38
|
+
id: req.id,
|
|
39
|
+
result: { echo: req.params },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('wrapMcpServer + wrapMcpClient round-trip', () => {
|
|
43
|
+
it('delivers a signed request, runs the handler, and returns a verified result', async () => {
|
|
44
|
+
const server = wrapMcpServer(echo, serverOptions())
|
|
45
|
+
const call = wrapMcpClient((raw) => server(raw), clientOptions())
|
|
46
|
+
|
|
47
|
+
const response = await call({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'planner' } })
|
|
48
|
+
|
|
49
|
+
expect(response).toEqual({ jsonrpc: '2.0', id: 7, result: { echo: { name: 'planner' } } })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('hands the underlying handler PLAIN json-rpc (no envelope, no signature)', async () => {
|
|
53
|
+
const handler = vi.fn(echo)
|
|
54
|
+
const server = wrapMcpServer(handler, serverOptions())
|
|
55
|
+
const call = wrapMcpClient((raw) => server(raw), clientOptions())
|
|
56
|
+
|
|
57
|
+
await call({ jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'file://x' } })
|
|
58
|
+
|
|
59
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
60
|
+
expect(handler).toHaveBeenCalledWith({ jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'file://x' } })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('tamper detection', () => {
|
|
65
|
+
function tamperContent(raw: WireEnvelope): WireEnvelope {
|
|
66
|
+
const env = JSON.parse(raw as string)
|
|
67
|
+
env.body.content = `${env.body.content} TAMPERED`
|
|
68
|
+
return JSON.stringify(env)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it('a request tampered after signing is rejected by the server (handler never runs)', async () => {
|
|
72
|
+
const handler = vi.fn(echo)
|
|
73
|
+
const server = wrapMcpServer(handler, serverOptions())
|
|
74
|
+
const codec = createMcpClientCodec(clientOptions())
|
|
75
|
+
|
|
76
|
+
const { raw } = await codec.encodeRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } })
|
|
77
|
+
const response = await codec.decodeResponse(await server(tamperContent(raw)))
|
|
78
|
+
|
|
79
|
+
expect(handler).not.toHaveBeenCalled()
|
|
80
|
+
expect(response.error).toBeDefined()
|
|
81
|
+
expect(response.result).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('replay detection', () => {
|
|
86
|
+
it('rejects the second delivery of an identical signed request', async () => {
|
|
87
|
+
const replayCache = new InMemoryReplayCache()
|
|
88
|
+
const handler = vi.fn(echo)
|
|
89
|
+
const server = wrapMcpServer(handler, serverOptions({ receive: { secretResolver: async () => SECRET, replayCache } }))
|
|
90
|
+
const codec = createMcpClientCodec(clientOptions())
|
|
91
|
+
|
|
92
|
+
const { raw } = await codec.encodeRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })
|
|
93
|
+
const first = await codec.decodeResponse(await server(raw))
|
|
94
|
+
const second = await codec.decodeResponse(await server(raw))
|
|
95
|
+
|
|
96
|
+
expect(first.result).toBeDefined()
|
|
97
|
+
expect(second.error).toBeDefined()
|
|
98
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('defaults to replay protection even when no cache is configured', async () => {
|
|
102
|
+
const handler = vi.fn(echo)
|
|
103
|
+
const server = wrapMcpServer(handler, serverOptions()) // no replayCache provided
|
|
104
|
+
const codec = createMcpClientCodec(clientOptions())
|
|
105
|
+
|
|
106
|
+
const { raw } = await codec.encodeRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })
|
|
107
|
+
await codec.decodeResponse(await server(raw))
|
|
108
|
+
const replayed = await codec.decodeResponse(await server(raw))
|
|
109
|
+
|
|
110
|
+
expect(replayed.error).toBeDefined()
|
|
111
|
+
expect(handler).toHaveBeenCalledOnce()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('response authenticity', () => {
|
|
116
|
+
it('client rejects a response whose signature does not verify', async () => {
|
|
117
|
+
const server = wrapMcpServer(echo, serverOptions())
|
|
118
|
+
const codec = createMcpClientCodec(clientOptions({ receive: { secretResolver: async () => 'WRONG-SECRET' } }))
|
|
119
|
+
|
|
120
|
+
const { raw } = await codec.encodeRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })
|
|
121
|
+
const responseRaw = await server(raw)
|
|
122
|
+
|
|
123
|
+
await expect(codec.decodeResponse(responseRaw)).rejects.toThrow(/verif/i)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('recipient binding (cross-server relay defense)', () => {
|
|
128
|
+
it('server rejects an envelope addressed to a different recipient', async () => {
|
|
129
|
+
const handler = vi.fn(echo)
|
|
130
|
+
const server = wrapMcpServer(handler, serverOptions({ selfAgentId: 'agent.server' }))
|
|
131
|
+
// a validly-signed request addressed to 'agent.other', relayed to this server
|
|
132
|
+
const misaddressed = await signEnvelopeHmac(
|
|
133
|
+
createEnvelope({
|
|
134
|
+
sender: 'agent.client',
|
|
135
|
+
recipient: 'agent.other',
|
|
136
|
+
intent: 'TASK',
|
|
137
|
+
content: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} }),
|
|
138
|
+
}),
|
|
139
|
+
SECRET,
|
|
140
|
+
'client-k1',
|
|
141
|
+
)
|
|
142
|
+
const responseRaw = await server(encodeEnvelope(misaddressed, 'json'))
|
|
143
|
+
|
|
144
|
+
// decode the error with a codec that matches the real server identity
|
|
145
|
+
const codec = createMcpClientCodec(clientOptions({ peerAgentId: 'agent.server' }))
|
|
146
|
+
const response = await codec.decodeResponse(responseRaw)
|
|
147
|
+
|
|
148
|
+
expect(handler).not.toHaveBeenCalled()
|
|
149
|
+
expect(response.error).toBeDefined()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('sender binding (response spoofing defense)', () => {
|
|
154
|
+
it('client rejects a validly-signed response whose sender is not the expected peer', async () => {
|
|
155
|
+
const codec = createMcpClientCodec(clientOptions({ peerAgentId: 'agent.server' }))
|
|
156
|
+
// a response signed with the shared secret but by a DIFFERENT agent id
|
|
157
|
+
const spoofed = await signEnvelopeHmac(
|
|
158
|
+
createEnvelope({ sender: 'agent.evil', recipient: 'agent.client', intent: 'RESULT', content: JSON.stringify({ jsonrpc: '2.0', id: 1, result: 'pwned' }) }),
|
|
159
|
+
SECRET,
|
|
160
|
+
'evil-k1',
|
|
161
|
+
)
|
|
162
|
+
const spoofedRaw = encodeEnvelope(spoofed, 'json')
|
|
163
|
+
|
|
164
|
+
await expect(codec.decodeResponse(spoofedRaw)).rejects.toThrow(/sender|peer/i)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('correlation binding (response substitution defense)', () => {
|
|
169
|
+
it('client rejects a response whose correlationId does not match the request', async () => {
|
|
170
|
+
const server = wrapMcpServer(echo, serverOptions())
|
|
171
|
+
const codec = createMcpClientCodec(clientOptions())
|
|
172
|
+
|
|
173
|
+
const { raw } = await codec.encodeRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })
|
|
174
|
+
const responseRaw = await server(raw)
|
|
175
|
+
|
|
176
|
+
// decode while asserting it answers a DIFFERENT request id
|
|
177
|
+
await expect(codec.decodeResponse(responseRaw, { expectCorrelationId: 'some-other-message-id' })).rejects.toThrow(/correlation/i)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createEnvelope, type ProtocolEnvelope } from './protocol'
|
|
2
|
+
import { InMemoryReplayCache } from './protocolReplay'
|
|
3
|
+
import {
|
|
4
|
+
encodeEnvelope,
|
|
5
|
+
receiveEnvelope,
|
|
6
|
+
type ReceiveEnvelopeOptions,
|
|
7
|
+
type WireEnvelope,
|
|
8
|
+
type WireFormat,
|
|
9
|
+
} from './protocolTransport'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MCP hardening middleware.
|
|
13
|
+
*
|
|
14
|
+
* MCP messages are plain JSON-RPC 2.0 and, as shipped, carry no signature or
|
|
15
|
+
* replay protection. These wrappers put an AIP envelope around each message on
|
|
16
|
+
* the wire — signing, TTL-bounding, and replay-checking every request and
|
|
17
|
+
* response — while the underlying MCP handler still receives plain JSON-RPC.
|
|
18
|
+
* "Zero app changes": your handler signature does not change.
|
|
19
|
+
*
|
|
20
|
+
* The wrappers enforce four bindings beyond signature verification:
|
|
21
|
+
* - recipient binding: the server only runs the handler when the envelope is
|
|
22
|
+
* addressed to it (`recipient === selfAgentId`) — defeats cross-server relay.
|
|
23
|
+
* - sender binding: the client only accepts responses from its expected peer
|
|
24
|
+
* (`sender === peerAgentId`) — defeats response spoofing.
|
|
25
|
+
* - correlation binding: the client only accepts a response whose
|
|
26
|
+
* `correlationId` matches the request it sent — defeats response substitution.
|
|
27
|
+
* - replay protection on by default (an `InMemoryReplayCache` if none supplied).
|
|
28
|
+
*
|
|
29
|
+
* Verification reuses the transport pipeline (`receiveEnvelope`), so it composes
|
|
30
|
+
* directly with a Redis-backed replay cache (`createRedisReplayStore`) and
|
|
31
|
+
* fleet-wide revocation (`withRevocationCheck`) via the `receive` options.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type JsonRpcId = string | number | null
|
|
35
|
+
|
|
36
|
+
export interface JsonRpcRequest {
|
|
37
|
+
jsonrpc: '2.0'
|
|
38
|
+
id: JsonRpcId
|
|
39
|
+
method: string
|
|
40
|
+
params?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JsonRpcResponse {
|
|
44
|
+
jsonrpc: '2.0'
|
|
45
|
+
id: JsonRpcId
|
|
46
|
+
result?: unknown
|
|
47
|
+
error?: { code: number; message: string; data?: unknown }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type McpJsonRpcHandler = (request: JsonRpcRequest) => JsonRpcResponse | Promise<JsonRpcResponse>
|
|
51
|
+
|
|
52
|
+
export interface McpSecurityOptions {
|
|
53
|
+
/** This peer's agent id, used as the envelope `sender` and enforced as the inbound `recipient`. */
|
|
54
|
+
selfAgentId: string
|
|
55
|
+
/** The peer's agent id: set as the envelope `recipient` (client) and enforced as the response `sender`. */
|
|
56
|
+
peerAgentId?: string
|
|
57
|
+
/** Signs an unsigned envelope, e.g. `(e) => signEnvelopeHmac(e, secret, keyId)`. */
|
|
58
|
+
sign: (envelope: Omit<ProtocolEnvelope, 'signature'>) => Promise<ProtocolEnvelope>
|
|
59
|
+
/** Inbound verification options: signature material resolver, replay cache, clock skew, etc. */
|
|
60
|
+
receive?: ReceiveEnvelopeOptions
|
|
61
|
+
/** Wire format for the envelope on the wire. Default `compact`. */
|
|
62
|
+
wireFormat?: WireFormat
|
|
63
|
+
/** Envelope TTL in ms. Default 60_000. */
|
|
64
|
+
ttlMs?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EncodedRequest {
|
|
68
|
+
raw: WireEnvelope
|
|
69
|
+
/** The envelope `messageId` generated for this request; bind the response's `correlationId` to it. */
|
|
70
|
+
messageId: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DecodeResponseOptions {
|
|
74
|
+
/** When set, require the response `correlationId` to equal this value (the request's messageId). */
|
|
75
|
+
expectCorrelationId?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function jsonRpcError(id: JsonRpcId, code: number, message: string): JsonRpcResponse {
|
|
79
|
+
return { jsonrpc: '2.0', id, error: { code, message } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Verification options with signature required and a replay cache guaranteed (secure defaults). */
|
|
83
|
+
function hardenedReceive(options: McpSecurityOptions, replayCache: ReceiveEnvelopeOptions['replayCache']): ReceiveEnvelopeOptions {
|
|
84
|
+
return { requireSignature: true, ...options.receive, replayCache }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wraps an existing MCP JSON-RPC handler so every inbound message is verified
|
|
89
|
+
* (signature + replay + TTL + recipient binding) before the handler runs, and
|
|
90
|
+
* every response is signed. Returns a function over the AIP wire envelope.
|
|
91
|
+
*/
|
|
92
|
+
export function wrapMcpServer(handler: McpJsonRpcHandler, options: McpSecurityOptions): (raw: WireEnvelope) => Promise<WireEnvelope> {
|
|
93
|
+
const wireFormat = options.wireFormat ?? 'compact'
|
|
94
|
+
const ttlMs = options.ttlMs ?? 60_000
|
|
95
|
+
// Replay protection is on by default; supply a Redis-backed cache via options.receive for scale.
|
|
96
|
+
const replayCache = options.receive?.replayCache ?? new InMemoryReplayCache()
|
|
97
|
+
const receiveOptions = hardenedReceive(options, replayCache)
|
|
98
|
+
|
|
99
|
+
async function signAndEncode(envelope: Omit<ProtocolEnvelope, 'signature'>): Promise<WireEnvelope> {
|
|
100
|
+
return encodeEnvelope(await options.sign(envelope), wireFormat)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reply(recipient: string | undefined, intent: 'RESULT' | 'ERROR', response: JsonRpcResponse, correlationId?: string) {
|
|
104
|
+
return signAndEncode(
|
|
105
|
+
createEnvelope({ sender: options.selfAgentId, recipient, intent, content: JSON.stringify(response), correlationId, ttlMs }),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return async (raw: WireEnvelope): Promise<WireEnvelope> => {
|
|
110
|
+
const received = await receiveEnvelope(raw, receiveOptions)
|
|
111
|
+
const senderHint = received.envelope?.header.sender
|
|
112
|
+
const correlationId = received.envelope?.header.messageId
|
|
113
|
+
|
|
114
|
+
if (!received.ok || !received.envelope) {
|
|
115
|
+
const reason = received.diagnostics.find((d) => d.level === 'error')?.message ?? 'AIP verification failed'
|
|
116
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32600, reason), correlationId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Recipient binding: refuse to act on an envelope not addressed to this agent.
|
|
120
|
+
if (received.envelope.header.recipient !== options.selfAgentId) {
|
|
121
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32600, 'Recipient mismatch: envelope not addressed to this agent'), correlationId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let request: JsonRpcRequest
|
|
125
|
+
try {
|
|
126
|
+
request = JSON.parse(received.envelope.body.content) as JsonRpcRequest
|
|
127
|
+
} catch {
|
|
128
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32700, 'Parse error: envelope body is not JSON-RPC'), correlationId)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let response: JsonRpcResponse
|
|
132
|
+
try {
|
|
133
|
+
response = await handler(request)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
response = jsonRpcError(request.id ?? null, -32603, error instanceof Error ? error.message : 'Internal error')
|
|
136
|
+
}
|
|
137
|
+
return reply(received.envelope.header.sender, 'RESULT', response, correlationId)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface McpClientCodec {
|
|
142
|
+
encodeRequest(request: JsonRpcRequest): Promise<EncodedRequest>
|
|
143
|
+
decodeResponse(raw: WireEnvelope, options?: DecodeResponseOptions): Promise<JsonRpcResponse>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Signs outbound requests and verifies inbound responses (with sender + correlation binding). */
|
|
147
|
+
export function createMcpClientCodec(options: McpSecurityOptions): McpClientCodec {
|
|
148
|
+
const wireFormat = options.wireFormat ?? 'compact'
|
|
149
|
+
const ttlMs = options.ttlMs ?? 60_000
|
|
150
|
+
const replayCache = options.receive?.replayCache ?? new InMemoryReplayCache()
|
|
151
|
+
const receiveOptions = hardenedReceive(options, replayCache)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
async encodeRequest(request: JsonRpcRequest): Promise<EncodedRequest> {
|
|
155
|
+
const envelope = createEnvelope({
|
|
156
|
+
sender: options.selfAgentId,
|
|
157
|
+
recipient: options.peerAgentId,
|
|
158
|
+
intent: 'TASK',
|
|
159
|
+
content: JSON.stringify(request),
|
|
160
|
+
ttlMs,
|
|
161
|
+
})
|
|
162
|
+
return { raw: encodeEnvelope(await options.sign(envelope), wireFormat), messageId: envelope.header.messageId }
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async decodeResponse(raw: WireEnvelope, decodeOptions: DecodeResponseOptions = {}): Promise<JsonRpcResponse> {
|
|
166
|
+
const received = await receiveEnvelope(raw, receiveOptions)
|
|
167
|
+
if (!received.ok || !received.envelope) {
|
|
168
|
+
const reason = received.diagnostics.find((d) => d.level === 'error')?.message ?? 'unknown'
|
|
169
|
+
throw new Error(`AIP response verification failed: ${reason}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Sender binding: the response must come from the expected peer.
|
|
173
|
+
if (!options.peerAgentId) {
|
|
174
|
+
throw new Error('createMcpClientCodec requires peerAgentId to bind the response sender')
|
|
175
|
+
}
|
|
176
|
+
if (received.envelope.header.sender !== options.peerAgentId) {
|
|
177
|
+
throw new Error(`AIP response sender '${received.envelope.header.sender}' is not the expected peer '${options.peerAgentId}'`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Correlation binding: the response must answer the request we sent.
|
|
181
|
+
if (decodeOptions.expectCorrelationId !== undefined && received.envelope.body.correlationId !== decodeOptions.expectCorrelationId) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`AIP response correlationId '${received.envelope.body.correlationId ?? ''}' does not match request '${decodeOptions.expectCorrelationId}'`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return JSON.parse(received.envelope.body.content) as JsonRpcResponse
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Wraps a transport send function (`stdio`, HTTP, ...) so callers issue and
|
|
194
|
+
* receive plain JSON-RPC while AIP signing, verification, and request/response
|
|
195
|
+
* correlation happen on the wire.
|
|
196
|
+
*/
|
|
197
|
+
export function wrapMcpClient(
|
|
198
|
+
send: (raw: WireEnvelope) => Promise<WireEnvelope>,
|
|
199
|
+
options: McpSecurityOptions,
|
|
200
|
+
): (request: JsonRpcRequest) => Promise<JsonRpcResponse> {
|
|
201
|
+
const codec = createMcpClientCodec(options)
|
|
202
|
+
return async (request: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
|
203
|
+
const { raw, messageId } = await codec.encodeRequest(request)
|
|
204
|
+
return codec.decodeResponse(await send(raw), { expectCorrelationId: messageId })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bootstrapRuntimePolicyEnforcer, createPolicyEnforcer, isRuntimeMode } from './policyEnforcer'
|
|
3
|
+
import type { RuntimePolicy } from './runtimePolicy'
|
|
4
|
+
|
|
5
|
+
const policy: RuntimePolicy = {
|
|
6
|
+
version: '1.0',
|
|
7
|
+
name: 'gluv-runtime-policy',
|
|
8
|
+
status: 'active',
|
|
9
|
+
slo_defaults: {
|
|
10
|
+
drop_pct_max: 0.1,
|
|
11
|
+
p99_ms_max: 20,
|
|
12
|
+
},
|
|
13
|
+
tuning: {
|
|
14
|
+
batch_size: {
|
|
15
|
+
low_traffic: [4, 8],
|
|
16
|
+
medium_traffic: [8, 32],
|
|
17
|
+
high_traffic: [32, 64],
|
|
18
|
+
},
|
|
19
|
+
inflight_cap: {
|
|
20
|
+
low_traffic: [16, 64],
|
|
21
|
+
medium_traffic: [64, 256],
|
|
22
|
+
high_traffic: [256, 1024],
|
|
23
|
+
},
|
|
24
|
+
retry_backoff_ms: {
|
|
25
|
+
low_traffic: [1, 2],
|
|
26
|
+
medium_traffic: [2, 8],
|
|
27
|
+
high_traffic: [4, 16],
|
|
28
|
+
},
|
|
29
|
+
max_retry_attempts: 3,
|
|
30
|
+
retriable_status_codes: [503],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('policy enforcer', () => {
|
|
35
|
+
it('selects binary batch for high concurrency', () => {
|
|
36
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
37
|
+
expect(enforcer.selectMode({ concurrency: 100 })).toBe('http-binary-batch')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('selects ws-batch for latency-sensitive low concurrency', () => {
|
|
41
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
42
|
+
expect(enforcer.selectMode({ concurrency: 10, latencySensitive: true })).toBe('ws-batch')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns tuning ranges and remediation actions', () => {
|
|
46
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
47
|
+
const decision = enforcer.tune({
|
|
48
|
+
trafficClass: 'high',
|
|
49
|
+
dropPct: 1,
|
|
50
|
+
p99Ms: 30,
|
|
51
|
+
})
|
|
52
|
+
expect(decision.batchSizeRange).toEqual([32, 64])
|
|
53
|
+
expect(decision.inflightCapRange).toEqual([256, 1024])
|
|
54
|
+
expect(decision.retryBackoffMsRange).toEqual([4, 16])
|
|
55
|
+
expect(decision.actions.length).toBeGreaterThan(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('retries only retriable status and under attempt cap', () => {
|
|
59
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
60
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 503: overloaded'), attempt: 1 })).toBe(true)
|
|
61
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 400: bad request'), attempt: 1 })).toBe(false)
|
|
62
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 503: overloaded'), attempt: 3 })).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('asserts invariants', () => {
|
|
66
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
67
|
+
expect(() =>
|
|
68
|
+
enforcer.assertInvariant({
|
|
69
|
+
signatureVerification: true,
|
|
70
|
+
canonicalization: true,
|
|
71
|
+
replayDefense: true,
|
|
72
|
+
ttlClockSkewEnforcement: true,
|
|
73
|
+
}),
|
|
74
|
+
).not.toThrow()
|
|
75
|
+
expect(() =>
|
|
76
|
+
enforcer.assertInvariant({
|
|
77
|
+
signatureVerification: false,
|
|
78
|
+
canonicalization: true,
|
|
79
|
+
replayDefense: true,
|
|
80
|
+
ttlClockSkewEnforcement: true,
|
|
81
|
+
}),
|
|
82
|
+
).toThrow(/Invariant violation/i)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('checks runtime mode literals', () => {
|
|
86
|
+
expect(isRuntimeMode('http-binary-batch')).toBe(true)
|
|
87
|
+
expect(isRuntimeMode('invalid-mode')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('bootstraps policy + enforcer in one call', async () => {
|
|
91
|
+
const bootstrapped = await bootstrapRuntimePolicyEnforcer({
|
|
92
|
+
path: '/virtual/AI_RUNTIME_POLICY.json',
|
|
93
|
+
readTextFile: async () => JSON.stringify(policy),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(bootstrapped.policy.name).toBe('gluv-runtime-policy')
|
|
97
|
+
expect(bootstrapped.enforcer.selectMode({ concurrency: 100 })).toBe('http-binary-batch')
|
|
98
|
+
})
|
|
99
|
+
})
|