@mudraid/adapter-node 1.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 +201 -0
- package/README.md +34 -0
- package/dist/controlLoop.d.ts +57 -0
- package/dist/controlLoop.js +245 -0
- package/dist/controlLoop.js.map +1 -0
- package/dist/decideClient.d.ts +16 -0
- package/dist/decideClient.js +22 -0
- package/dist/decideClient.js.map +1 -0
- package/dist/executionBinding.d.ts +13 -0
- package/dist/executionBinding.js +40 -0
- package/dist/executionBinding.js.map +1 -0
- package/dist/httpAuthority.d.ts +32 -0
- package/dist/httpAuthority.js +196 -0
- package/dist/httpAuthority.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/signedBundle.d.ts +20 -0
- package/dist/signedBundle.js +129 -0
- package/dist/signedBundle.js.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/package.json +38 -0
- package/src/controlLoop.ts +387 -0
- package/src/decideClient.ts +25 -0
- package/src/executionBinding.ts +43 -0
- package/src/httpAuthority.ts +185 -0
- package/src/index.ts +38 -0
- package/src/signedBundle.ts +130 -0
- package/src/types.ts +128 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Public-key-only verification of the control plane's signed bundle contract. */
|
|
2
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export type JsonObject = Record<string, unknown>;
|
|
5
|
+
export interface BundleBinding {
|
|
6
|
+
readonly platformId: string;
|
|
7
|
+
readonly environment: string;
|
|
8
|
+
readonly resource: string;
|
|
9
|
+
}
|
|
10
|
+
export interface VerifiedBundle {
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly digest: string;
|
|
13
|
+
readonly expiresAt: number;
|
|
14
|
+
readonly surface: Readonly<JsonObject>;
|
|
15
|
+
readonly actions: Readonly<Record<string, Readonly<JsonObject>>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function object(value: unknown): JsonObject {
|
|
19
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid object');
|
|
20
|
+
return value as JsonObject;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Python sort_keys/ensure_ascii canonicalization used by the authority. */
|
|
24
|
+
export function canonicalJson(value: unknown): string {
|
|
25
|
+
if (value === null || typeof value === 'boolean') return JSON.stringify(value);
|
|
26
|
+
if (typeof value === 'number') {
|
|
27
|
+
if (!Number.isSafeInteger(value)) throw new Error('Unsupported canonical number');
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
34
|
+
const record = object(value);
|
|
35
|
+
// Python compares Unicode code points; JavaScript's default compares UTF-16.
|
|
36
|
+
const compare = (a: string, b: string): number => {
|
|
37
|
+
const aa = Array.from(a, c => c.codePointAt(0)!);
|
|
38
|
+
const bb = Array.from(b, c => c.codePointAt(0)!);
|
|
39
|
+
for (let i = 0; i < Math.min(aa.length, bb.length); i++) {
|
|
40
|
+
if (aa[i] !== bb[i]) return aa[i]! - bb[i]!;
|
|
41
|
+
}
|
|
42
|
+
return aa.length - bb.length;
|
|
43
|
+
};
|
|
44
|
+
return `{${Object.keys(record).sort(compare).map(k => `${canonicalJson(k)}:${canonicalJson(record[k])}`).join(',')}}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function instant(value: unknown): number {
|
|
48
|
+
if (typeof value !== 'string' || !/(Z|[+-]\d{2}:\d{2})$/.test(value)) throw new Error('Invalid timestamp');
|
|
49
|
+
const time = Date.parse(value);
|
|
50
|
+
if (!Number.isFinite(time)) throw new Error('Invalid timestamp');
|
|
51
|
+
return time;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyClaims(claims: JsonObject, encoded: unknown, pem: unknown): void {
|
|
55
|
+
if (typeof encoded !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded) || !encoded) throw new Error('Invalid signature');
|
|
56
|
+
if (typeof pem !== 'string') throw new Error('Unknown signing key');
|
|
57
|
+
const key = createPublicKey(pem);
|
|
58
|
+
if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw new Error('Invalid signing key');
|
|
59
|
+
if (!verify('RSA-SHA256', Buffer.from(canonicalJson(claims)), key, Buffer.from(encoded, 'base64'))) throw new Error('Invalid signature');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function equal(actual: unknown, expected: unknown): void {
|
|
63
|
+
if (actual !== expected) throw new Error('Bundle binding mismatch');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function freeze<T>(value: T): T {
|
|
67
|
+
if (value !== null && typeof value === 'object') {
|
|
68
|
+
for (const child of Object.values(value)) freeze(child);
|
|
69
|
+
Object.freeze(value);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Verify signatures, content, exact tenant binding, time and rollback fencing. */
|
|
75
|
+
export function verifyBundle(
|
|
76
|
+
input: unknown, keys: Readonly<Record<string, string>>, binding: BundleBinding,
|
|
77
|
+
active?: VerifiedBundle, now = Date.now(),
|
|
78
|
+
): VerifiedBundle {
|
|
79
|
+
const envelope = object(input);
|
|
80
|
+
const payload = object(envelope['payload']);
|
|
81
|
+
const claims = object(envelope['signature_claims']);
|
|
82
|
+
const version = envelope['bundle_version'];
|
|
83
|
+
if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) throw new Error('Invalid bundle version');
|
|
84
|
+
equal(envelope['schema_version'], '1.0');
|
|
85
|
+
equal(payload['schema_version'], '1.0');
|
|
86
|
+
equal(payload['bundle_version'], version);
|
|
87
|
+
const digest = createHash('sha256').update(canonicalJson(payload)).digest('hex');
|
|
88
|
+
equal(envelope['payload_digest'], digest);
|
|
89
|
+
const keyId = envelope['signature_key_id'];
|
|
90
|
+
if (typeof keyId !== 'string' || !Object.hasOwn(keys, keyId)) throw new Error('Unknown signing key');
|
|
91
|
+
const profile = 'mudraid.bundle.signature/1';
|
|
92
|
+
equal(envelope['signature_profile'], profile);
|
|
93
|
+
equal(envelope['signature_algorithm'], 'RS256');
|
|
94
|
+
equal(claims['profile'], profile);
|
|
95
|
+
equal(claims['algorithm'], 'RS256');
|
|
96
|
+
equal(claims['key_id'], keyId);
|
|
97
|
+
verifyClaims(claims, envelope['signature_value'], keys[keyId]);
|
|
98
|
+
equal(claims['payload_digest'], digest);
|
|
99
|
+
equal(claims['bundle_version'], version);
|
|
100
|
+
const content = object(payload['content']);
|
|
101
|
+
const surface = object(content['surface']);
|
|
102
|
+
for (const [field, expected] of [
|
|
103
|
+
['platform_id', binding.platformId], ['environment', binding.environment],
|
|
104
|
+
['canonical_resource_uri', binding.resource],
|
|
105
|
+
] as const) {
|
|
106
|
+
if (!expected.trim()) throw new Error('Unbound surface');
|
|
107
|
+
equal(surface[field], expected);
|
|
108
|
+
equal(claims[field], expected);
|
|
109
|
+
}
|
|
110
|
+
const starts = instant(claims['not_before']);
|
|
111
|
+
const expires = instant(claims['expires_at']);
|
|
112
|
+
if (starts > now || expires <= now || expires <= starts) throw new Error('Bundle outside validity window');
|
|
113
|
+
const evaluation = object(content['evaluation']);
|
|
114
|
+
for (const [field, expected] of Object.entries({mode: 'live', on_timeout: 'deny', on_error: 'deny', on_unmapped_action: 'deny', on_stale_bundle: 'deny', forward: 'once', decide_required: true, retry_forwarded_request: false})) equal(evaluation[field], expected);
|
|
115
|
+
const matcher = object(content['matcher']);
|
|
116
|
+
equal(matcher['kind'], 'mcp_tool_exact');
|
|
117
|
+
const entries = matcher['actions'];
|
|
118
|
+
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 10000) throw new Error('Invalid action map');
|
|
119
|
+
const actions: Record<string, Readonly<JsonObject>> = Object.create(null);
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const action = object(entry);
|
|
122
|
+
const tool = action['tool_name'];
|
|
123
|
+
const actionKey = action['action_key'];
|
|
124
|
+
if (typeof tool !== 'string' || !tool || Buffer.byteLength(tool) > 512 || Object.hasOwn(actions, tool)) throw new Error('Invalid or ambiguous tool');
|
|
125
|
+
if (typeof actionKey !== 'string' || !actionKey || Buffer.byteLength(actionKey) > 512) throw new Error('Invalid action');
|
|
126
|
+
actions[tool] = JSON.parse(JSON.stringify(action)) as JsonObject;
|
|
127
|
+
}
|
|
128
|
+
if (active && (version < active.version || (version === active.version && digest !== active.digest))) throw new Error('Bundle rollback or conflict');
|
|
129
|
+
return freeze({version, digest, expiresAt: expires, surface: JSON.parse(JSON.stringify(surface)) as JsonObject, actions});
|
|
130
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed decision vocabulary for the MudraID Node server adapter.
|
|
3
|
+
*
|
|
4
|
+
* These types are the TypeScript mirror of the portable adapter-decision
|
|
5
|
+
* contract `mudraid.adapter.decision/1`
|
|
6
|
+
* (`shared/mudraid_contracts/mudraid_contracts/adapters/decision.py`) and of the
|
|
7
|
+
* Python middleware V2 control loop
|
|
8
|
+
* (`sdks/mudraid-middleware-python/src/mudraid_middleware/_v2_control_loop.py`).
|
|
9
|
+
*
|
|
10
|
+
* The whole point of this adapter is EXACT parity: the same facts must produce
|
|
11
|
+
* the same outcome + adapter code as the Kong Lua handler and the Python
|
|
12
|
+
* middleware. So every value here is a closed union — never a bare `string` —
|
|
13
|
+
* so a divergent code is a compile error, not a silent runtime drift.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Versioned identifier of the portable contract this adapter reproduces. */
|
|
17
|
+
export const ADAPTER_DECISION_CONTRACT_VERSION = 'mudraid.adapter.decision/1' as const;
|
|
18
|
+
|
|
19
|
+
/** Reserved request-header prefix, stripped before evaluation (contract A03-04). */
|
|
20
|
+
export const RESERVED_HEADER_PREFIX = 'x-mudraid-' as const;
|
|
21
|
+
|
|
22
|
+
/** Bounded canonical action-name length in bytes (matches `MAX_TOOL_NAME_LEN`). */
|
|
23
|
+
export const MAX_TOOL_NAME_LEN = 512 as const;
|
|
24
|
+
|
|
25
|
+
/** Closed outcome vocabulary — byte-identical to the contract's `OUTCOMES`. */
|
|
26
|
+
export type Outcome = 'allow' | 'deny' | 'not_safely_decided';
|
|
27
|
+
|
|
28
|
+
/** Which tier produced the reason: pre-decision framing vs. authorization. */
|
|
29
|
+
export type ReasonTier = 'transport' | 'authorization';
|
|
30
|
+
|
|
31
|
+
/** Shape of the parsed request body, as the fact extractor classifies it. */
|
|
32
|
+
export type JsonShape = 'object' | 'array' | 'scalar' | 'not_json';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stable, agent-facing adapter error codes. Identical to the codes the Kong Lua
|
|
36
|
+
* handler and the Python middleware surface, so a Node caller sees the SAME code
|
|
37
|
+
* for the SAME failure. `null` on an allow / pass-through (nothing to surface).
|
|
38
|
+
*/
|
|
39
|
+
export type AdapterCode =
|
|
40
|
+
| 'ENFORCE_METHOD_NOT_ALLOWED'
|
|
41
|
+
| 'ENFORCE_BODY_TOO_LARGE'
|
|
42
|
+
| 'ENFORCE_BODY_UNREADABLE'
|
|
43
|
+
| 'ENFORCE_MALFORMED_REQUEST'
|
|
44
|
+
| 'ENFORCE_BATCH_UNSUPPORTED'
|
|
45
|
+
| 'ENFORCE_MESSAGE_NOT_ALLOWED'
|
|
46
|
+
| 'ENFORCE_ACTION_UNMAPPED'
|
|
47
|
+
| 'ENFORCE_NO_VALID_BUNDLE'
|
|
48
|
+
| 'ENFORCE_DECISION_DENY'
|
|
49
|
+
| 'ENFORCE_DECIDE_UNAVAILABLE';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The failure-aware outcome vocabulary a live `/decide` call reports back to the
|
|
53
|
+
* control loop.
|
|
54
|
+
*
|
|
55
|
+
* - `allow` / `deny` — an authority decision was reached;
|
|
56
|
+
* - `timeout` / `error` / `unreachable` / `unconfigured` /
|
|
57
|
+
* `credential_unconfigured` — the call could not be completed; every one is
|
|
58
|
+
* deny-closed (`not_safely_decided`), never optimistically allowed.
|
|
59
|
+
*/
|
|
60
|
+
export type DecideStatus =
|
|
61
|
+
| 'allow'
|
|
62
|
+
| 'deny'
|
|
63
|
+
| 'timeout'
|
|
64
|
+
| 'error'
|
|
65
|
+
| 'unreachable'
|
|
66
|
+
| 'unconfigured'
|
|
67
|
+
| 'credential_unconfigured';
|
|
68
|
+
|
|
69
|
+
/** The outcome of one live `/decide` call, as the adapter sees it. */
|
|
70
|
+
export interface DecideResult {
|
|
71
|
+
readonly status: DecideStatus;
|
|
72
|
+
/** A `/decide`-supplied deny reason code (only meaningful on `status: 'deny'`). */
|
|
73
|
+
readonly reason?: string;
|
|
74
|
+
/** Correlation id forwarded as trusted context on an allow. */
|
|
75
|
+
readonly decisionId?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The injectable `/decide` seam. The control loop invokes it EXACTLY at the
|
|
80
|
+
* `/decide` branch (a mapped `tools/call` on an active bundle) and nowhere else.
|
|
81
|
+
* Tests inject a fake so no network is required; the real HTTP client is a
|
|
82
|
+
* deferred remainder of this story.
|
|
83
|
+
*/
|
|
84
|
+
export type DecideClient = (action: string) => Promise<DecideResult>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The normalized facts a single evaluation consumes. Mirrors the portable
|
|
88
|
+
* contract's decision facts. Fact *extraction* from a live framework request is
|
|
89
|
+
* a deferred remainder; the decision *tree* lives only in `evaluateV2`, so
|
|
90
|
+
* extraction and decision never drift into two competing policy copies.
|
|
91
|
+
*/
|
|
92
|
+
export interface RequestFacts {
|
|
93
|
+
/** Whether this surface is a bundled (protected) MudraID surface. */
|
|
94
|
+
readonly protected: boolean;
|
|
95
|
+
/** Reserved `x-mudraid-*` headers the client presented (original case kept). */
|
|
96
|
+
readonly reservedHeadersPresented?: readonly string[];
|
|
97
|
+
/** Whether a verified signed bundle is currently active. */
|
|
98
|
+
readonly bundleActive: boolean;
|
|
99
|
+
readonly method: string;
|
|
100
|
+
readonly bodyReadable?: boolean;
|
|
101
|
+
readonly bodyTooLarge?: boolean;
|
|
102
|
+
readonly jsonShape?: JsonShape;
|
|
103
|
+
readonly jsonrpc?: string | null;
|
|
104
|
+
readonly rpcMethod?: string | null;
|
|
105
|
+
readonly toolName?: string | null;
|
|
106
|
+
/** Whether an exact canonical action is mapped for this tool. */
|
|
107
|
+
readonly actionMapped?: boolean;
|
|
108
|
+
/** Resolved canonical action name, if distinct from `toolName`. */
|
|
109
|
+
readonly action?: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A trusted-context header injected downstream, only on a bound allow. */
|
|
113
|
+
export type TrustedContextHeader = readonly [name: string, value: string];
|
|
114
|
+
|
|
115
|
+
/** The normalized outcome of one V2 evaluation. */
|
|
116
|
+
export interface Decision {
|
|
117
|
+
readonly outcome: Outcome;
|
|
118
|
+
readonly reasonCode: string;
|
|
119
|
+
readonly reasonTier: ReasonTier;
|
|
120
|
+
readonly httpStatus: number;
|
|
121
|
+
readonly adapterCode: AdapterCode | null;
|
|
122
|
+
/** Stable agent-facing message; never contains secrets or token material. */
|
|
123
|
+
readonly message: string;
|
|
124
|
+
/** Reserved headers stripped from the request before evaluation. */
|
|
125
|
+
readonly strippedReservedHeaders: readonly string[];
|
|
126
|
+
/** Injected only on an authorized allow; empty on every pass-through/deny. */
|
|
127
|
+
readonly trustedContext: readonly TrustedContextHeader[];
|
|
128
|
+
}
|