@kashscript/hudhud 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/LICENSE +133 -0
- package/README.md +67 -0
- package/dist/adapters/prisma.d.ts +170 -0
- package/dist/adapters/prisma.d.ts.map +1 -0
- package/dist/adapters/prisma.js +185 -0
- package/dist/adapters/prisma.js.map +1 -0
- package/dist/anchor/index.d.ts +55 -0
- package/dist/anchor/index.d.ts.map +1 -0
- package/dist/anchor/index.js +97 -0
- package/dist/anchor/index.js.map +1 -0
- package/dist/certificate/certificate.d.ts +115 -0
- package/dist/certificate/certificate.d.ts.map +1 -0
- package/dist/certificate/certificate.js +221 -0
- package/dist/certificate/certificate.js.map +1 -0
- package/dist/certificate/html.d.ts +10 -0
- package/dist/certificate/html.d.ts.map +1 -0
- package/dist/certificate/html.js +141 -0
- package/dist/certificate/html.js.map +1 -0
- package/dist/certificate/index.d.ts +3 -0
- package/dist/certificate/index.d.ts.map +1 -0
- package/dist/certificate/index.js +5 -0
- package/dist/certificate/index.js.map +1 -0
- package/dist/did-registry/index.d.ts +3 -0
- package/dist/did-registry/index.d.ts.map +1 -0
- package/dist/did-registry/index.js +10 -0
- package/dist/did-registry/index.js.map +1 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +65 -0
- package/dist/errors.js.map +1 -0
- package/dist/exception/index.d.ts +97 -0
- package/dist/exception/index.d.ts.map +1 -0
- package/dist/exception/index.js +144 -0
- package/dist/exception/index.js.map +1 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +36 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger/event-schema.d.ts +97 -0
- package/dist/ledger/event-schema.d.ts.map +1 -0
- package/dist/ledger/event-schema.js +54 -0
- package/dist/ledger/event-schema.js.map +1 -0
- package/dist/ledger/index.d.ts +7 -0
- package/dist/ledger/index.d.ts.map +1 -0
- package/dist/ledger/index.js +14 -0
- package/dist/ledger/index.js.map +1 -0
- package/dist/ledger/mutex.d.ts +9 -0
- package/dist/ledger/mutex.d.ts.map +1 -0
- package/dist/ledger/mutex.js +36 -0
- package/dist/ledger/mutex.js.map +1 -0
- package/dist/ledger/store.d.ts +31 -0
- package/dist/ledger/store.d.ts.map +1 -0
- package/dist/ledger/store.js +66 -0
- package/dist/ledger/store.js.map +1 -0
- package/dist/ledger/writer.d.ts +69 -0
- package/dist/ledger/writer.d.ts.map +1 -0
- package/dist/ledger/writer.js +167 -0
- package/dist/ledger/writer.js.map +1 -0
- package/dist/mcp/allowlist.d.ts +53 -0
- package/dist/mcp/allowlist.d.ts.map +1 -0
- package/dist/mcp/allowlist.js +65 -0
- package/dist/mcp/allowlist.js.map +1 -0
- package/dist/mcp/client.d.ts +68 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +198 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/privacy/index.d.ts +21 -0
- package/dist/privacy/index.d.ts.map +1 -0
- package/dist/privacy/index.js +96 -0
- package/dist/privacy/index.js.map +1 -0
- package/dist/runtime/dispatcher.d.ts +27 -0
- package/dist/runtime/dispatcher.d.ts.map +1 -0
- package/dist/runtime/dispatcher.js +66 -0
- package/dist/runtime/dispatcher.js.map +1 -0
- package/dist/runtime/escalation.d.ts +98 -0
- package/dist/runtime/escalation.d.ts.map +1 -0
- package/dist/runtime/escalation.js +188 -0
- package/dist/runtime/escalation.js.map +1 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/runner.d.ts +78 -0
- package/dist/runtime/runner.d.ts.map +1 -0
- package/dist/runtime/runner.js +145 -0
- package/dist/runtime/runner.js.map +1 -0
- package/dist/scope/index.d.ts +19 -0
- package/dist/scope/index.d.ts.map +1 -0
- package/dist/scope/index.js +44 -0
- package/dist/scope/index.js.map +1 -0
- package/dist/sop/compiler.d.ts +36 -0
- package/dist/sop/compiler.d.ts.map +1 -0
- package/dist/sop/compiler.js +54 -0
- package/dist/sop/compiler.js.map +1 -0
- package/dist/sop/index.d.ts +3 -0
- package/dist/sop/index.d.ts.map +1 -0
- package/dist/sop/index.js +5 -0
- package/dist/sop/index.js.map +1 -0
- package/dist/sop/service.d.ts +40 -0
- package/dist/sop/service.d.ts.map +1 -0
- package/dist/sop/service.js +140 -0
- package/dist/sop/service.js.map +1 -0
- package/package.json +112 -0
- package/src/adapters/prisma.ts +310 -0
- package/src/anchor/index.ts +149 -0
- package/src/certificate/certificate.ts +340 -0
- package/src/certificate/html.ts +156 -0
- package/src/certificate/index.ts +4 -0
- package/src/did-registry/index.ts +11 -0
- package/src/errors.ts +79 -0
- package/src/exception/index.ts +244 -0
- package/src/identity/index.ts +82 -0
- package/src/index.ts +33 -0
- package/src/ledger/event-schema.ts +61 -0
- package/src/ledger/index.ts +22 -0
- package/src/ledger/mutex.ts +37 -0
- package/src/ledger/store.ts +86 -0
- package/src/ledger/writer.ts +249 -0
- package/src/mcp/allowlist.ts +102 -0
- package/src/mcp/client.ts +284 -0
- package/src/mcp/index.ts +4 -0
- package/src/privacy/index.ts +123 -0
- package/src/runtime/dispatcher.ts +96 -0
- package/src/runtime/escalation.ts +292 -0
- package/src/runtime/index.ts +14 -0
- package/src/runtime/runner.ts +227 -0
- package/src/scope/index.ts +59 -0
- package/src/sop/compiler.ts +99 -0
- package/src/sop/index.ts +4 -0
- package/src/sop/service.ts +184 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kashscript/hudhud/ledger — ForensicLedgerService (the write side)
|
|
3
|
+
// ----------------------------------------------------------------------------
|
|
4
|
+
// The single append entry point: acquire the per-session mutex, resolve the
|
|
5
|
+
// chain tip, verify the caller stepIndex, compute the canonical block hash,
|
|
6
|
+
// verify the Ed25519 signature, and atomically insert. The self-signing path
|
|
7
|
+
// (`appendActorTransition`) resolves + signs INSIDE the mutex so two concurrent
|
|
8
|
+
// appends can never sign over a stale (prev, stepIndex). The chain-hash,
|
|
9
|
+
// signature sign/verify, and forensic scan are attest's (M1 seed); this module
|
|
10
|
+
// is the write-side orchestration. Ported from oreoasis-core-host/services/
|
|
11
|
+
// ledger-writer (M5); errors neutralized (append → LedgerAppendError, tamper →
|
|
12
|
+
// attest TamperError).
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
KASH_DID_PATTERN,
|
|
17
|
+
LedgerSignatureError,
|
|
18
|
+
chainBlockHash,
|
|
19
|
+
forensicConsistencyScan,
|
|
20
|
+
signLedgerBlock,
|
|
21
|
+
verifyLedgerSignature,
|
|
22
|
+
} from "@kashscript/attest";
|
|
23
|
+
import type { DidRegistry, ForensicScanReport, LedgerBlockRecord, LedgerSignaturePayload } from "@kashscript/attest";
|
|
24
|
+
|
|
25
|
+
import { LedgerAppendError } from "../errors";
|
|
26
|
+
import { SessionMutex } from "./mutex";
|
|
27
|
+
import { resolvePreviousBlockHash } from "./store";
|
|
28
|
+
import type { LedgerStore } from "./store";
|
|
29
|
+
|
|
30
|
+
export const ACTOR_CLASSES = ["AGENT", "SYSTEM_KERNEL", "OPERATOR_ADMIN", "COMPLIANCE_AUDITOR"] as const;
|
|
31
|
+
export type ActorClass = (typeof ACTOR_CLASSES)[number];
|
|
32
|
+
|
|
33
|
+
/** An in-process signing identity the host controls (the runtime key that signs
|
|
34
|
+
* autonomous agent actions). The private key stays captured behind `sign()`. */
|
|
35
|
+
export interface HostActorSigner {
|
|
36
|
+
readonly actorDid: string;
|
|
37
|
+
readonly actorClass: ActorClass;
|
|
38
|
+
sign(payload: LedgerSignaturePayload): Promise<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a HostActorSigner from a raw Ed25519 private key. `actorDid` must be a
|
|
42
|
+
* did:kash and be registered in the DidRegistry the service verifies against. */
|
|
43
|
+
export function createEd25519HostSigner(opts: {
|
|
44
|
+
readonly actorDid: string;
|
|
45
|
+
readonly actorClass: ActorClass;
|
|
46
|
+
readonly privateKey: Uint8Array;
|
|
47
|
+
}): HostActorSigner {
|
|
48
|
+
if (!KASH_DID_PATTERN.test(opts.actorDid)) {
|
|
49
|
+
throw new LedgerAppendError("host signer actorDid is not a valid did:kash:… identifier");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
actorDid: opts.actorDid,
|
|
53
|
+
actorClass: opts.actorClass,
|
|
54
|
+
sign: (payload) => signLedgerBlock(payload, opts.privateKey),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AppendTransitionLogInput {
|
|
59
|
+
readonly sessionId: string;
|
|
60
|
+
readonly stepIndex: number;
|
|
61
|
+
readonly actorDid: string;
|
|
62
|
+
readonly actorClass: ActorClass;
|
|
63
|
+
readonly payloadMutation: Readonly<Record<string, unknown>>;
|
|
64
|
+
/** `ed25519:<128-hex>` wire form — verified inside the service. */
|
|
65
|
+
readonly signature: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AppendedLedgerBlock {
|
|
69
|
+
readonly sessionId: string;
|
|
70
|
+
readonly stepIndex: number;
|
|
71
|
+
readonly actorDid: string;
|
|
72
|
+
readonly actorClass: ActorClass;
|
|
73
|
+
readonly previousBlockHash: string;
|
|
74
|
+
readonly currentTransactionBlockHash: string;
|
|
75
|
+
readonly signature: string;
|
|
76
|
+
readonly emittedAt: string;
|
|
77
|
+
readonly localLedgerId: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ForensicLedgerServiceDeps {
|
|
81
|
+
readonly ledger: LedgerStore;
|
|
82
|
+
readonly sessionMutex: SessionMutex;
|
|
83
|
+
readonly didRegistry: DidRegistry;
|
|
84
|
+
/** Required only for `appendActorTransition` (the self-signing path). */
|
|
85
|
+
readonly hostSigner?: HostActorSigner;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class ForensicLedgerService {
|
|
89
|
+
constructor(private readonly deps: ForensicLedgerServiceDeps) {}
|
|
90
|
+
|
|
91
|
+
/** Append a caller-pre-signed transition. @throws LedgerAppendError on any
|
|
92
|
+
* validation / signature / store failure. */
|
|
93
|
+
async appendTransitionLog(input: AppendTransitionLogInput): Promise<AppendedLedgerBlock> {
|
|
94
|
+
if (typeof input.sessionId !== "string" || input.sessionId.length === 0) {
|
|
95
|
+
throw new LedgerAppendError("sessionId is required");
|
|
96
|
+
}
|
|
97
|
+
if (typeof input.actorDid !== "string" || !KASH_DID_PATTERN.test(input.actorDid)) {
|
|
98
|
+
throw new LedgerAppendError("actorDid must be a valid did:kash:… identifier");
|
|
99
|
+
}
|
|
100
|
+
if (typeof input.signature !== "string" || input.signature.length === 0) {
|
|
101
|
+
throw new LedgerAppendError("signature is required");
|
|
102
|
+
}
|
|
103
|
+
if (!ACTOR_CLASSES.includes(input.actorClass)) {
|
|
104
|
+
throw new LedgerAppendError(`actorClass must be one of [${ACTOR_CLASSES.join(", ")}]`);
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isInteger(input.stepIndex) || input.stepIndex < 0) {
|
|
107
|
+
throw new LedgerAppendError("stepIndex must be a non-negative integer");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return this.deps.sessionMutex.run(input.sessionId, async () => {
|
|
111
|
+
const { previousBlockHash: resolvedPrev, nextStepIndex } = await resolvePreviousBlockHash(
|
|
112
|
+
this.deps.ledger,
|
|
113
|
+
input.sessionId,
|
|
114
|
+
);
|
|
115
|
+
if (input.stepIndex !== nextStepIndex) {
|
|
116
|
+
throw new LedgerAppendError(
|
|
117
|
+
`stepIndex ${input.stepIndex} does not match next expected ${nextStepIndex} for session ${input.sessionId}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return this.commitResolvedBlock({
|
|
121
|
+
sessionId: input.sessionId,
|
|
122
|
+
stepIndex: input.stepIndex,
|
|
123
|
+
actorDid: input.actorDid,
|
|
124
|
+
actorClass: input.actorClass,
|
|
125
|
+
payloadMutation: input.payloadMutation,
|
|
126
|
+
signature: input.signature,
|
|
127
|
+
resolvedPrev,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Self-signing append for the in-host autonomous actor: resolves + signs +
|
|
133
|
+
* verifies + inserts atomically inside the per-session mutex. Requires
|
|
134
|
+
* `deps.hostSigner`. */
|
|
135
|
+
async appendActorTransition(input: {
|
|
136
|
+
readonly sessionId: string;
|
|
137
|
+
readonly payloadMutation: Readonly<Record<string, unknown>>;
|
|
138
|
+
}): Promise<AppendedLedgerBlock> {
|
|
139
|
+
const signer = this.deps.hostSigner;
|
|
140
|
+
if (signer === undefined) {
|
|
141
|
+
throw new LedgerAppendError("appendActorTransition requires a configured hostSigner");
|
|
142
|
+
}
|
|
143
|
+
if (typeof input.sessionId !== "string" || input.sessionId.length === 0) {
|
|
144
|
+
throw new LedgerAppendError("sessionId is required");
|
|
145
|
+
}
|
|
146
|
+
if (input.payloadMutation === null || typeof input.payloadMutation !== "object" || Array.isArray(input.payloadMutation)) {
|
|
147
|
+
throw new LedgerAppendError("payloadMutation must be a plain object");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this.deps.sessionMutex.run(input.sessionId, async () => {
|
|
151
|
+
const { previousBlockHash: resolvedPrev, nextStepIndex } = await resolvePreviousBlockHash(
|
|
152
|
+
this.deps.ledger,
|
|
153
|
+
input.sessionId,
|
|
154
|
+
);
|
|
155
|
+
const signature = await signer.sign({
|
|
156
|
+
sessionId: input.sessionId,
|
|
157
|
+
stepIndex: nextStepIndex,
|
|
158
|
+
actorDid: signer.actorDid,
|
|
159
|
+
previousBlockHash: resolvedPrev,
|
|
160
|
+
payloadMutation: input.payloadMutation,
|
|
161
|
+
});
|
|
162
|
+
return this.commitResolvedBlock({
|
|
163
|
+
sessionId: input.sessionId,
|
|
164
|
+
stepIndex: nextStepIndex,
|
|
165
|
+
actorDid: signer.actorDid,
|
|
166
|
+
actorClass: signer.actorClass,
|
|
167
|
+
payloadMutation: input.payloadMutation,
|
|
168
|
+
signature,
|
|
169
|
+
resolvedPrev,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** MUST be called inside the session mutex — computes the canonical block
|
|
175
|
+
* hash, verifies the signature, and inserts. */
|
|
176
|
+
private async commitResolvedBlock(args: {
|
|
177
|
+
readonly sessionId: string;
|
|
178
|
+
readonly stepIndex: number;
|
|
179
|
+
readonly actorDid: string;
|
|
180
|
+
readonly actorClass: ActorClass;
|
|
181
|
+
readonly payloadMutation: Readonly<Record<string, unknown>>;
|
|
182
|
+
readonly signature: string;
|
|
183
|
+
readonly resolvedPrev: string;
|
|
184
|
+
}): Promise<AppendedLedgerBlock> {
|
|
185
|
+
const currentTransactionBlockHash = await chainBlockHash(
|
|
186
|
+
{
|
|
187
|
+
sessionId: args.sessionId,
|
|
188
|
+
stepIndex: args.stepIndex,
|
|
189
|
+
actorDid: args.actorDid,
|
|
190
|
+
payloadMutation: args.payloadMutation,
|
|
191
|
+
signature: args.signature,
|
|
192
|
+
},
|
|
193
|
+
args.resolvedPrev,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await verifyLedgerSignature({
|
|
198
|
+
signatureWire: args.signature,
|
|
199
|
+
payload: {
|
|
200
|
+
sessionId: args.sessionId,
|
|
201
|
+
stepIndex: args.stepIndex,
|
|
202
|
+
actorDid: args.actorDid,
|
|
203
|
+
previousBlockHash: args.resolvedPrev,
|
|
204
|
+
payloadMutation: args.payloadMutation,
|
|
205
|
+
},
|
|
206
|
+
didRegistry: this.deps.didRegistry,
|
|
207
|
+
});
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
const reason = cause instanceof LedgerSignatureError ? cause.reason : "UNKNOWN";
|
|
210
|
+
throw new LedgerAppendError(`Ed25519 signature verification failed (${reason})`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let record: LedgerBlockRecord;
|
|
214
|
+
try {
|
|
215
|
+
record = await this.deps.ledger.appendBlock({
|
|
216
|
+
sessionId: args.sessionId,
|
|
217
|
+
stepIndex: args.stepIndex,
|
|
218
|
+
previousBlockHash: args.resolvedPrev,
|
|
219
|
+
currentTransactionBlockHash,
|
|
220
|
+
actorDid: args.actorDid,
|
|
221
|
+
payloadMutation: args.payloadMutation,
|
|
222
|
+
signature: args.signature,
|
|
223
|
+
});
|
|
224
|
+
} catch (cause) {
|
|
225
|
+
throw new LedgerAppendError(`Ledger store write failed — ${cause instanceof Error ? cause.message : "unknown"}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
sessionId: record.sessionId,
|
|
230
|
+
stepIndex: record.stepIndex,
|
|
231
|
+
actorDid: record.actorDid,
|
|
232
|
+
actorClass: args.actorClass,
|
|
233
|
+
previousBlockHash: record.previousBlockHash,
|
|
234
|
+
currentTransactionBlockHash: record.currentTransactionBlockHash,
|
|
235
|
+
signature: record.signature,
|
|
236
|
+
emittedAt: record.emittedAt,
|
|
237
|
+
localLedgerId: record.localId,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Walk the whole chain for `sessionId` and re-verify hashes + signatures.
|
|
242
|
+
* Delegates to attest's `forensicConsistencyScan` (throws `TamperError` on
|
|
243
|
+
* the first variance: STEP_INDEX_GAP / PREVIOUS_HASH_MISMATCH /
|
|
244
|
+
* PAYLOAD_HASH_VARIANCE / SIGNATURE_VERIFY_FAILED). */
|
|
245
|
+
async forensicConsistencyScan(sessionId: string): Promise<ForensicScanReport> {
|
|
246
|
+
const rows = await this.deps.ledger.listBlocksForSession(sessionId);
|
|
247
|
+
return forensicConsistencyScan(sessionId, rows, this.deps.didRegistry);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kashscript/hudhud/mcp — MCP tool allowlist + per-tool argument governance
|
|
3
|
+
// ----------------------------------------------------------------------------
|
|
4
|
+
// Tracks which fully-qualified `<server>.<tool>` names each (institutionDid,
|
|
5
|
+
// serverName) pair may dispatch, plus optional per-tool argument allow/require
|
|
6
|
+
// key sets. Fully-qualified names route deterministically (no first-match-wins
|
|
7
|
+
// hijack). Ported verbatim from the oreoasis-core-host engine (M5, ADR-0016).
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export interface ToolArgSpec {
|
|
11
|
+
/** Whitelisted argument keys. Absent/empty ⇒ no restriction. */
|
|
12
|
+
readonly allowedKeys?: ReadonlyArray<string>;
|
|
13
|
+
/** Argument keys that MUST be present. */
|
|
14
|
+
readonly requiredKeys?: ReadonlyArray<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AllowlistEntry {
|
|
18
|
+
readonly institutionDid: string;
|
|
19
|
+
readonly serverName: string;
|
|
20
|
+
readonly transportProtocol: "HTTP_SSE" | "STDIO_STUB";
|
|
21
|
+
readonly endpointUrl: string;
|
|
22
|
+
/** Tool names WITHOUT the `<server>.` prefix. */
|
|
23
|
+
readonly allowlistTools: ReadonlyArray<string>;
|
|
24
|
+
readonly toolArgSpecs?: Readonly<Record<string, ToolArgSpec>>;
|
|
25
|
+
readonly isCircuitBroken: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ToolArgValidationResult =
|
|
29
|
+
| { ok: true }
|
|
30
|
+
| { ok: false; reason: "EXTRA_KEYS"; extraKeys: ReadonlyArray<string> }
|
|
31
|
+
| { ok: false; reason: "MISSING_REQUIRED"; missingKeys: ReadonlyArray<string> };
|
|
32
|
+
|
|
33
|
+
/** Validate arguments against a per-tool spec (no spec ⇒ ok). */
|
|
34
|
+
export function validateToolArgs(
|
|
35
|
+
args: Readonly<Record<string, unknown>>,
|
|
36
|
+
spec: ToolArgSpec | undefined,
|
|
37
|
+
): ToolArgValidationResult {
|
|
38
|
+
if (spec === undefined) return { ok: true };
|
|
39
|
+
const argKeys = Object.keys(args);
|
|
40
|
+
|
|
41
|
+
if (spec.allowedKeys !== undefined && spec.allowedKeys.length > 0) {
|
|
42
|
+
const allowed = new Set(spec.allowedKeys);
|
|
43
|
+
const extras = argKeys.filter((k) => !allowed.has(k));
|
|
44
|
+
if (extras.length > 0) return { ok: false, reason: "EXTRA_KEYS", extraKeys: extras };
|
|
45
|
+
}
|
|
46
|
+
if (spec.requiredKeys !== undefined && spec.requiredKeys.length > 0) {
|
|
47
|
+
const present = new Set(argKeys);
|
|
48
|
+
const missing = spec.requiredKeys.filter((k) => !present.has(k));
|
|
49
|
+
if (missing.length > 0) return { ok: false, reason: "MISSING_REQUIRED", missingKeys: missing };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `<server>.<tool>`: lowercase alnum/_/- segments, mandatory dot. */
|
|
55
|
+
export const FULLY_QUALIFIED_TOOL_PATTERN = /^[a-z0-9][a-z0-9_-]*\.[a-z0-9][a-z0-9._-]*$/;
|
|
56
|
+
/** Tool-name length cap. */
|
|
57
|
+
export const MAX_TOOL_NAME_LENGTH = 256;
|
|
58
|
+
|
|
59
|
+
export interface ToolNameDecomposition {
|
|
60
|
+
readonly serverName: string;
|
|
61
|
+
readonly toolLocalName: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Decompose a fully-qualified tool name → (server, localName), or null if malformed. */
|
|
65
|
+
export function decomposeToolName(fullyQualified: string): ToolNameDecomposition | null {
|
|
66
|
+
if (fullyQualified.length === 0 || fullyQualified.length > MAX_TOOL_NAME_LENGTH) return null;
|
|
67
|
+
if (!FULLY_QUALIFIED_TOOL_PATTERN.test(fullyQualified)) return null;
|
|
68
|
+
const dot = fullyQualified.indexOf(".");
|
|
69
|
+
return { serverName: fullyQualified.slice(0, dot), toolLocalName: fullyQualified.slice(dot + 1) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface McpAllowlistStore {
|
|
73
|
+
/** Resolve a fully-qualified tool for a tenant; null if the server row is
|
|
74
|
+
* unknown OR the local tool isn't on that server's allowlist. */
|
|
75
|
+
resolveForAction(institutionDid: string, fullyQualifiedTool: string): Promise<AllowlistEntry | null>;
|
|
76
|
+
upsert(entry: AllowlistEntry): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** In-memory store keyed by (institutionDid, serverName). O(1) resolve. */
|
|
80
|
+
export class InMemoryMcpAllowlistStore implements McpAllowlistStore {
|
|
81
|
+
private readonly byKey = new Map<string, AllowlistEntry>();
|
|
82
|
+
|
|
83
|
+
private static key(institutionDid: string, serverName: string): string {
|
|
84
|
+
return `${institutionDid}::${serverName}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async upsert(entry: AllowlistEntry): Promise<void> {
|
|
88
|
+
this.byKey.set(InMemoryMcpAllowlistStore.key(entry.institutionDid, entry.serverName), {
|
|
89
|
+
...entry,
|
|
90
|
+
allowlistTools: [...entry.allowlistTools],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async resolveForAction(institutionDid: string, fullyQualifiedTool: string): Promise<AllowlistEntry | null> {
|
|
95
|
+
const parts = decomposeToolName(fullyQualifiedTool);
|
|
96
|
+
if (parts === null) return null;
|
|
97
|
+
const entry = this.byKey.get(InMemoryMcpAllowlistStore.key(institutionDid, parts.serverName));
|
|
98
|
+
if (entry === undefined) return null;
|
|
99
|
+
if (!entry.allowlistTools.includes(parts.toolLocalName)) return null;
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kashscript/hudhud/mcp — McpClientPoolManager (JSON-RPC 2.0 + circuit breaker)
|
|
3
|
+
// ----------------------------------------------------------------------------
|
|
4
|
+
// Stateful HTTP/SSE JSON-RPC client pool. Owns:
|
|
5
|
+
// 1. a per-serverUrl circuit breaker — FAIL-CLOSED on the first transport
|
|
6
|
+
// failure so a wedged endpoint can't stall the agent loop (manual reset);
|
|
7
|
+
// 2. a hard timeout (default 5000ms) via AbortController, covering the body
|
|
8
|
+
// drain;
|
|
9
|
+
// 3. JSON-RPC 2.0 outbound framing + inbound structural conformance — a
|
|
10
|
+
// non-conformant response is a protocol failure and trips the breaker;
|
|
11
|
+
// 4. an injectable `fetchImpl` for tests.
|
|
12
|
+
// Application-level JSON-RPC `{error}` blocks do NOT trip the breaker.
|
|
13
|
+
//
|
|
14
|
+
// Ported from the oreoasis-core-host engine (M5); `McpTimeoutException` →
|
|
15
|
+
// neutral `McpTimeoutError`, telemetry made injectable (no node `process` dep).
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
import { McpTimeoutError } from "../errors";
|
|
19
|
+
|
|
20
|
+
export const MCP_HARD_TIMEOUT_MS = 5000 as const;
|
|
21
|
+
|
|
22
|
+
export interface McpClientPoolOptions {
|
|
23
|
+
readonly fetchImpl?: typeof fetch;
|
|
24
|
+
readonly timeoutMs?: number;
|
|
25
|
+
readonly nowMs?: () => number;
|
|
26
|
+
readonly defaultHeaders?: Readonly<Record<string, string>>;
|
|
27
|
+
/** Optional structured audit sink (default no-op — a library must not spam). */
|
|
28
|
+
readonly onTelemetry?: (payload: Record<string, unknown>) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface McpJsonRpcErrorBlock {
|
|
32
|
+
readonly code: number;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly data?: unknown;
|
|
35
|
+
}
|
|
36
|
+
export interface McpJsonRpcSuccess {
|
|
37
|
+
readonly jsonrpc: "2.0";
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly result: unknown;
|
|
40
|
+
}
|
|
41
|
+
export interface McpJsonRpcError {
|
|
42
|
+
readonly jsonrpc: "2.0";
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly error: McpJsonRpcErrorBlock;
|
|
45
|
+
}
|
|
46
|
+
export type McpJsonRpcResponse = McpJsonRpcSuccess | McpJsonRpcError;
|
|
47
|
+
|
|
48
|
+
export type McpToolCallResult =
|
|
49
|
+
| {
|
|
50
|
+
readonly ok: true;
|
|
51
|
+
readonly result: unknown;
|
|
52
|
+
readonly jsonRpcId: string;
|
|
53
|
+
readonly latencyMs: number;
|
|
54
|
+
readonly transportProtocol: "HTTP_SSE" | "STDIO_STUB";
|
|
55
|
+
readonly forwardedArguments: Readonly<Record<string, unknown>>;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly ok: false;
|
|
59
|
+
readonly error: McpJsonRpcErrorBlock;
|
|
60
|
+
readonly jsonRpcId: string;
|
|
61
|
+
readonly latencyMs: number;
|
|
62
|
+
readonly transportProtocol: "HTTP_SSE" | "STDIO_STUB";
|
|
63
|
+
readonly forwardedArguments: Readonly<Record<string, unknown>>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface CircuitBreakerSnapshot {
|
|
67
|
+
readonly serverUrl: string;
|
|
68
|
+
readonly open: boolean;
|
|
69
|
+
readonly trippedAtMs: number | null;
|
|
70
|
+
readonly lastFailureReason: string | null;
|
|
71
|
+
readonly consecutiveFailures: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface BreakerState {
|
|
75
|
+
open: boolean;
|
|
76
|
+
trippedAtMs: number | null;
|
|
77
|
+
lastFailureReason: string | null;
|
|
78
|
+
consecutiveFailures: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emptyBreaker(): BreakerState {
|
|
82
|
+
return { open: false, trippedAtMs: null, lastFailureReason: null, consecutiveFailures: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class McpClientPoolManager {
|
|
86
|
+
private readonly breakers = new Map<string, BreakerState>();
|
|
87
|
+
private readonly fetchImpl: typeof fetch;
|
|
88
|
+
private readonly timeoutMs: number;
|
|
89
|
+
private readonly now: () => number;
|
|
90
|
+
private readonly defaultHeaders: Readonly<Record<string, string>>;
|
|
91
|
+
private readonly onTelemetry: (payload: Record<string, unknown>) => void;
|
|
92
|
+
private rpcIdCounter = 0;
|
|
93
|
+
|
|
94
|
+
constructor(options: McpClientPoolOptions = {}) {
|
|
95
|
+
this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch);
|
|
96
|
+
if (typeof this.fetchImpl !== "function") {
|
|
97
|
+
throw new Error("McpClientPoolManager: no global fetch available — pass fetchImpl in options");
|
|
98
|
+
}
|
|
99
|
+
this.timeoutMs = options.timeoutMs ?? MCP_HARD_TIMEOUT_MS;
|
|
100
|
+
if (this.timeoutMs <= 0 || this.timeoutMs > 60_000) {
|
|
101
|
+
throw new Error(`McpClientPoolManager: timeoutMs must be (0, 60000] — got ${this.timeoutMs}`);
|
|
102
|
+
}
|
|
103
|
+
this.now = options.nowMs ?? (() => Date.now());
|
|
104
|
+
this.defaultHeaders = Object.freeze({ ...(options.defaultHeaders ?? {}) });
|
|
105
|
+
this.onTelemetry = options.onTelemetry ?? (() => {});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getFetchImpl(): typeof fetch {
|
|
109
|
+
return this.fetchImpl;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private mintRpcId(): string {
|
|
113
|
+
this.rpcIdCounter += 1;
|
|
114
|
+
return `mcp-${this.now().toString(36)}-${this.rpcIdCounter.toString(36)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private getBreaker(serverUrl: string): BreakerState {
|
|
118
|
+
let b = this.breakers.get(serverUrl);
|
|
119
|
+
if (b === undefined) {
|
|
120
|
+
b = emptyBreaker();
|
|
121
|
+
this.breakers.set(serverUrl, b);
|
|
122
|
+
}
|
|
123
|
+
return b;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
snapshot(serverUrl: string): CircuitBreakerSnapshot {
|
|
127
|
+
const b = this.getBreaker(serverUrl);
|
|
128
|
+
return {
|
|
129
|
+
serverUrl,
|
|
130
|
+
open: b.open,
|
|
131
|
+
trippedAtMs: b.trippedAtMs,
|
|
132
|
+
lastFailureReason: b.lastFailureReason,
|
|
133
|
+
consecutiveFailures: b.consecutiveFailures,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
resetCircuit(serverUrl: string): void {
|
|
138
|
+
this.breakers.set(serverUrl, emptyBreaker());
|
|
139
|
+
this.onTelemetry({ action: "circuit-reset", serverUrl, timestamp: this.now() });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
isCircuitBroken(serverUrl: string): boolean {
|
|
143
|
+
return this.getBreaker(serverUrl).open;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Execute a JSON-RPC `tools/call`. Transport failures trip the breaker +
|
|
147
|
+
* throw `McpTimeoutError`; JSON-RPC `{error}` blocks bubble as `{ok:false}`. */
|
|
148
|
+
async executeMcpToolCall(
|
|
149
|
+
serverUrl: string,
|
|
150
|
+
toolName: string,
|
|
151
|
+
argumentsBlock: Record<string, unknown>,
|
|
152
|
+
): Promise<McpToolCallResult> {
|
|
153
|
+
if (typeof serverUrl !== "string" || serverUrl.length === 0) {
|
|
154
|
+
throw new McpTimeoutError(this.timeoutMs, "executeMcpToolCall: serverUrl is required");
|
|
155
|
+
}
|
|
156
|
+
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
157
|
+
throw new McpTimeoutError(this.timeoutMs, "executeMcpToolCall: toolName is required");
|
|
158
|
+
}
|
|
159
|
+
if (argumentsBlock === null || typeof argumentsBlock !== "object") {
|
|
160
|
+
throw new McpTimeoutError(this.timeoutMs, "executeMcpToolCall: argumentsBlock must be a non-null object");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const breaker = this.getBreaker(serverUrl);
|
|
164
|
+
if (breaker.open) {
|
|
165
|
+
this.onTelemetry({ action: "dispatch-refused", reason: "CIRCUIT_OPEN", serverUrl, toolName });
|
|
166
|
+
throw new McpTimeoutError(
|
|
167
|
+
this.timeoutMs,
|
|
168
|
+
`MCP server '${serverUrl}' circuit is open (last failure: ${breaker.lastFailureReason ?? "<unknown>"})`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const jsonRpcId = this.mintRpcId();
|
|
173
|
+
const requestEnvelope = {
|
|
174
|
+
jsonrpc: "2.0" as const,
|
|
175
|
+
id: jsonRpcId,
|
|
176
|
+
method: "tools/call",
|
|
177
|
+
params: { name: toolName, arguments: argumentsBlock },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const startMs = this.now();
|
|
182
|
+
const timeoutHandle = setTimeout(() => controller.abort(new Error(`MCP_HARD_TIMEOUT_${this.timeoutMs}MS`)), this.timeoutMs);
|
|
183
|
+
|
|
184
|
+
let response: Response;
|
|
185
|
+
try {
|
|
186
|
+
response = await this.fetchImpl(serverUrl, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
Accept: "application/json, text/event-stream",
|
|
191
|
+
"X-Json-Rpc-Id": jsonRpcId,
|
|
192
|
+
...this.defaultHeaders,
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify(requestEnvelope),
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
});
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
clearTimeout(timeoutHandle);
|
|
199
|
+
return this.tripAndRaise(
|
|
200
|
+
serverUrl,
|
|
201
|
+
toolName,
|
|
202
|
+
jsonRpcId,
|
|
203
|
+
startMs,
|
|
204
|
+
cause instanceof Error && cause.name === "AbortError" ? "TRANSPORT_TIMEOUT" : "TRANSPORT_NETWORK_FAILURE",
|
|
205
|
+
cause,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
clearTimeout(timeoutHandle);
|
|
211
|
+
return this.tripAndRaise(serverUrl, toolName, jsonRpcId, startMs, `TRANSPORT_HTTP_${response.status}`, new Error(`HTTP ${response.status}`));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = await response.json();
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
clearTimeout(timeoutHandle);
|
|
219
|
+
return this.tripAndRaise(serverUrl, toolName, jsonRpcId, startMs, "TRANSPORT_JSON_PARSE_FAILED", cause);
|
|
220
|
+
}
|
|
221
|
+
clearTimeout(timeoutHandle);
|
|
222
|
+
|
|
223
|
+
const validated = validateJsonRpcResponse(parsed, jsonRpcId);
|
|
224
|
+
if (validated === null) {
|
|
225
|
+
return this.tripAndRaise(serverUrl, toolName, jsonRpcId, startMs, "TRANSPORT_JSON_RPC_SHAPE_INVALID", new Error("bad JSON-RPC shape"));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
breaker.consecutiveFailures = 0;
|
|
229
|
+
breaker.lastFailureReason = null;
|
|
230
|
+
|
|
231
|
+
const latencyMs = this.now() - startMs;
|
|
232
|
+
const transportProtocol = response.headers.get("Content-Type")?.toLowerCase().includes("text/event-stream")
|
|
233
|
+
? ("HTTP_SSE" as const)
|
|
234
|
+
: ("STDIO_STUB" as const);
|
|
235
|
+
|
|
236
|
+
if ("error" in validated) {
|
|
237
|
+
return { ok: false, error: validated.error, jsonRpcId, latencyMs, transportProtocol, forwardedArguments: Object.freeze({ ...argumentsBlock }) };
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, result: validated.result, jsonRpcId, latencyMs, transportProtocol, forwardedArguments: Object.freeze({ ...argumentsBlock }) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private tripAndRaise(serverUrl: string, toolName: string, jsonRpcId: string, startMs: number, reason: string, cause: unknown): never {
|
|
243
|
+
const breaker = this.getBreaker(serverUrl);
|
|
244
|
+
breaker.open = true;
|
|
245
|
+
breaker.trippedAtMs = this.now();
|
|
246
|
+
breaker.lastFailureReason = reason;
|
|
247
|
+
breaker.consecutiveFailures += 1;
|
|
248
|
+
const latencyMs = this.now() - startMs;
|
|
249
|
+
this.onTelemetry({
|
|
250
|
+
action: "circuit-trip",
|
|
251
|
+
reason,
|
|
252
|
+
serverUrl,
|
|
253
|
+
toolName,
|
|
254
|
+
jsonRpcId,
|
|
255
|
+
latencyMs,
|
|
256
|
+
causeMessage: cause instanceof Error ? cause.message : String(cause),
|
|
257
|
+
});
|
|
258
|
+
throw new McpTimeoutError(this.timeoutMs, `MCP transport failure for '${serverUrl}' (${reason}); breaker tripped`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Structural JSON-RPC 2.0 response conformance (exactly one of result|error). */
|
|
263
|
+
function validateJsonRpcResponse(candidate: unknown, expectedId: string): McpJsonRpcResponse | null {
|
|
264
|
+
if (candidate === null || typeof candidate !== "object") return null;
|
|
265
|
+
const c = candidate as Record<string, unknown>;
|
|
266
|
+
if (c.jsonrpc !== "2.0") return null;
|
|
267
|
+
if (typeof c.id !== "string" && typeof c.id !== "number") return null;
|
|
268
|
+
const responseIdStr = typeof c.id === "string" ? c.id : String(c.id);
|
|
269
|
+
if (responseIdStr !== expectedId) return null;
|
|
270
|
+
const hasResult = Object.prototype.hasOwnProperty.call(c, "result");
|
|
271
|
+
const hasError = Object.prototype.hasOwnProperty.call(c, "error");
|
|
272
|
+
if (hasResult === hasError) return null;
|
|
273
|
+
if (hasResult) return { jsonrpc: "2.0", id: responseIdStr, result: c.result };
|
|
274
|
+
const e = c.error;
|
|
275
|
+
if (e === null || typeof e !== "object") return null;
|
|
276
|
+
const eo = e as Record<string, unknown>;
|
|
277
|
+
if (typeof eo.code !== "number" || typeof eo.message !== "string") return null;
|
|
278
|
+
const errorBlock: McpJsonRpcErrorBlock = {
|
|
279
|
+
code: eo.code,
|
|
280
|
+
message: eo.message,
|
|
281
|
+
...(Object.prototype.hasOwnProperty.call(eo, "data") ? { data: eo.data } : {}),
|
|
282
|
+
};
|
|
283
|
+
return { jsonrpc: "2.0", id: responseIdStr, error: errorBlock };
|
|
284
|
+
}
|
package/src/mcp/index.ts
ADDED