@mcpose/audit 2.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +440 -28
  3. package/dist/chain.d.ts +49 -6
  4. package/dist/chain.d.ts.map +1 -1
  5. package/dist/chain.js +227 -28
  6. package/dist/chain.js.map +1 -1
  7. package/dist/index.d.ts +5 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +3 -1
  12. package/dist/middleware.d.ts.map +1 -1
  13. package/dist/middleware.js +499 -85
  14. package/dist/middleware.js.map +1 -1
  15. package/dist/sensitivity.d.ts +11 -2
  16. package/dist/sensitivity.d.ts.map +1 -1
  17. package/dist/sensitivity.js +18 -2
  18. package/dist/sensitivity.js.map +1 -1
  19. package/dist/signingKey.d.ts +8 -0
  20. package/dist/signingKey.d.ts.map +1 -1
  21. package/dist/signingKey.js +21 -4
  22. package/dist/signingKey.js.map +1 -1
  23. package/dist/subjectKeyStore.d.ts +17 -0
  24. package/dist/subjectKeyStore.d.ts.map +1 -0
  25. package/dist/subjectKeyStore.js +37 -0
  26. package/dist/subjectKeyStore.js.map +1 -0
  27. package/dist/types.d.ts +170 -18
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/verify.d.ts +24 -0
  30. package/dist/verify.d.ts.map +1 -0
  31. package/dist/verify.js +66 -0
  32. package/dist/verify.js.map +1 -0
  33. package/package.json +43 -15
  34. package/src/__tests__/chain.batch.test.ts +30 -0
  35. package/src/__tests__/chain.test.ts +433 -0
  36. package/src/__tests__/erasable.test.ts +499 -0
  37. package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
  38. package/src/__tests__/middleware.http.test.ts +172 -0
  39. package/src/__tests__/middleware.rejection.test.ts +183 -0
  40. package/src/__tests__/middleware.robustness.test.ts +809 -0
  41. package/src/__tests__/middleware.test.ts +1121 -0
  42. package/src/__tests__/middleware.unusableResult.test.ts +141 -0
  43. package/src/__tests__/properties.test.ts +764 -0
  44. package/src/__tests__/sensitivity.test.ts +72 -0
  45. package/src/__tests__/signingKey.test.ts +76 -0
  46. package/src/chain.ts +317 -0
  47. package/src/index.ts +31 -0
  48. package/src/middleware.ts +781 -0
  49. package/src/sensitivity.ts +33 -0
  50. package/src/signingKey.ts +35 -0
  51. package/src/subjectKeyStore.ts +38 -0
  52. package/src/types.ts +287 -0
  53. package/src/verify.ts +92 -0
@@ -0,0 +1,33 @@
1
+ import type {
2
+ SensitivityTier,
3
+ SensitivityResolverFn,
4
+ SensitivityOverrideFn,
5
+ } from './types.js';
6
+
7
+ const TIERS: ReadonlySet<string> = new Set(['low', 'medium', 'high']);
8
+
9
+ function validTier(value: unknown): SensitivityTier {
10
+ // Fail CLOSED: anything that is not a known tier resolves to 'high'
11
+ // (encrypt). Guards against typos in the map and misbehaving overrides.
12
+ return TIERS.has(value as string) ? (value as SensitivityTier) : 'high';
13
+ }
14
+
15
+ /**
16
+ * Builds a resolver from a static tool→tier map, with an optional dynamic
17
+ * override that receives the map's resolution as its fourth argument.
18
+ *
19
+ * Unknown tools resolve to `'high'` (encrypt by default). The lookup uses
20
+ * `Object.hasOwn`, so prototype-inherited keys (a tool named `toString` or
21
+ * `constructor` — tool names are attacker-controlled) cannot bypass that
22
+ * default.
23
+ */
24
+ export function createSensitivityResolver(
25
+ map: Record<string, SensitivityTier>,
26
+ override?: SensitivityOverrideFn,
27
+ ): SensitivityResolverFn {
28
+ return (tool, identity, args) => {
29
+ const mapTier = validTier(Object.hasOwn(map, tool) ? map[tool] : undefined);
30
+ if (override) return validTier(override(tool, identity, args, mapTier));
31
+ return mapTier;
32
+ };
33
+ }
@@ -0,0 +1,35 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import type { SigningKeyProvider } from './types.js';
3
+
4
+ // keyId derivation label. v1 used bare SHA256(secret), which let anyone
5
+ // holding a manifest brute-force a low-entropy secret offline from the
6
+ // published keyId. HMAC with a domain label is not invertible into a
7
+ // dictionary check any faster than attacking the signature itself, and
8
+ // keeps keyId stable per secret. See ADR-0004.
9
+ const DOMAIN_KEYID = 'mcpose/v2/keyid';
10
+
11
+ /**
12
+ * In-process HMAC-SHA256 signing provider.
13
+ *
14
+ * The secret must be high-entropy (>= 32 random bytes). `keyId` is PUBLIC —
15
+ * it is published in every ReplayManifest — so it must never be used as key
16
+ * material (ADR-0003), and a guessable secret is offline-attackable from
17
+ * any published manifest.
18
+ */
19
+ export function createDefaultSigningKeyProvider(
20
+ secret: Buffer | string,
21
+ ): SigningKeyProvider {
22
+ // The secret is handed to createHmac as given. Normalizing a string to a
23
+ // Buffer first was a no-op: createHmac encodes a string key as utf8, byte
24
+ // for byte what Buffer.from produces, so no input could tell the two
25
+ // apart. The equivalence is pinned by a test (#110).
26
+ const keyId = createHmac('sha256', secret).update(DOMAIN_KEYID).digest('hex');
27
+
28
+ return {
29
+ algorithm: 'HMAC-SHA256',
30
+ keyId,
31
+ async sign(data: Buffer): Promise<Buffer> {
32
+ return createHmac('sha256', secret).update(data).digest();
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,38 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type { SubjectKeyStore } from './types.js';
3
+
4
+ /**
5
+ * Reference {@link SubjectKeyStore}, backed by a `Map` in this process.
6
+ *
7
+ * NOT DURABLE, and not for production. The keys live only in this process's
8
+ * heap, so a restart erases every subject at once and a second proxy instance
9
+ * cannot decrypt what the first recorded. Both are silent failures: the chain
10
+ * still verifies, and the loss shows up only when someone tries to read a
11
+ * payload back.
12
+ *
13
+ * A real deployment needs a durable, access-controlled store — a KMS, an HSM,
14
+ * or an encrypted table whose read path is audited — because in erasable mode
15
+ * this store IS the confidentiality of every high-tier payload it holds a key
16
+ * for (ADR-0018).
17
+ */
18
+ export function createInMemorySubjectKeyStore(): SubjectKeyStore {
19
+ const keys = new Map<string, Buffer>();
20
+
21
+ return {
22
+ async getOrCreate(subjectId: string): Promise<Buffer> {
23
+ const existing = keys.get(subjectId);
24
+ if (existing !== undefined) return existing;
25
+ // 256-bit, random rather than derived: a key recomputable from the
26
+ // signing secret could not be destroyed, which is the whole point.
27
+ const created = randomBytes(32);
28
+ keys.set(subjectId, created);
29
+ return created;
30
+ },
31
+ async destroy(subjectId: string) {
32
+ keys.delete(subjectId);
33
+ // Idempotent: an unknown subject still gets a tombstone, so a repeated
34
+ // erasure request produces evidence rather than an error.
35
+ return { destroyedAt: new Date().toISOString() };
36
+ },
37
+ };
38
+ }
package/src/types.ts ADDED
@@ -0,0 +1,287 @@
1
+ import type {
2
+ Identity,
3
+ PromptMiddleware,
4
+ ProxyIdentity,
5
+ RejectionReason,
6
+ ToolMiddleware,
7
+ } from 'mcpose';
8
+
9
+ // ── Sensitivity ────────────────────────────────────────────────────────────────
10
+
11
+ export type SensitivityTier = 'low' | 'medium' | 'high';
12
+
13
+ export type SensitivityResolverFn = (
14
+ tool: string,
15
+ identity: Identity,
16
+ args: Record<string, unknown>,
17
+ ) => SensitivityTier;
18
+
19
+ /**
20
+ * Override passed to `createSensitivityResolver`. Receives the static map's
21
+ * resolution as `mapTier` (already defaulted to `'high'` for unknown tools)
22
+ * so it can fall back: `(tool, id, args, mapTier) => mapTier`.
23
+ */
24
+ export type SensitivityOverrideFn = (
25
+ tool: string,
26
+ identity: Identity,
27
+ args: Record<string, unknown>,
28
+ mapTier: SensitivityTier,
29
+ ) => SensitivityTier;
30
+
31
+ // ── Signing ────────────────────────────────────────────────────────────────────
32
+
33
+ export interface SigningKeyProvider {
34
+ sign(data: Buffer): Promise<Buffer>;
35
+ keyId: string;
36
+ algorithm: 'HMAC-SHA256';
37
+ }
38
+
39
+ // ── Cryptographic erasure ──────────────────────────────────────────────────────
40
+
41
+ /** Evidence that a subject's key was destroyed, returned by `destroy`. */
42
+ export interface SubjectKeyTombstone {
43
+ /** ISO timestamp of the destruction. */
44
+ destroyedAt: string;
45
+ }
46
+
47
+ /**
48
+ * Custody of the per-subject keys that make erasable mode erasable (ADR-0018).
49
+ *
50
+ * Supplying one to `AuditOptions.keyStore` switches the audit layer into
51
+ * erasable mode: per-event encryption keys and the keyed payload hashes then
52
+ * derive from the stored subject key rather than from the signing secret, so
53
+ * `destroy(subjectId)` makes that subject's recorded payloads permanently
54
+ * unreadable and unconfirmable. The chain and the manifest are untouched.
55
+ *
56
+ * These keys are private key material with the same handling rules as the
57
+ * signing secret: they must never be logged, exported, or written into an
58
+ * event, a manifest, or telemetry. An implementation that stores them in the
59
+ * clear voids the encryption guarantee for every subject in it.
60
+ */
61
+ export interface SubjectKeyStore {
62
+ /**
63
+ * The subject's 256-bit key, created at random on first use. Called before
64
+ * every audited call in erasable mode, so it must not be memoized in a way
65
+ * that outlives `destroy`: a subject that calls again after erasure gets a
66
+ * fresh key, and the old events stay dead.
67
+ *
68
+ * A rejection fails the audited call (the pre-call failure stage) rather
69
+ * than letting it run unaudited.
70
+ */
71
+ getOrCreate(subjectId: string): Promise<Buffer>;
72
+ /**
73
+ * Permanently removes the subject's key and returns the tombstone that
74
+ * evidences the erasure. Idempotent: destroying an unknown subject still
75
+ * returns a tombstone, because "this subject holds no key" is the state the
76
+ * caller asked for either way.
77
+ */
78
+ destroy(subjectId: string): Promise<SubjectKeyTombstone>;
79
+ }
80
+
81
+ // ── Audit events ───────────────────────────────────────────────────────────────
82
+
83
+ export interface AuditEventBase {
84
+ /** Equals the ProxyContext requestId. */
85
+ id: string;
86
+ /** ISO timestamp captured before the upstream call started. */
87
+ startedAt: string;
88
+ /** ISO timestamp captured after the upstream call settled. */
89
+ endedAt: string;
90
+ sessionId?: string;
91
+ identity: Identity;
92
+ delegatedFrom?: Identity[];
93
+ /**
94
+ * The proxy instance that recorded this event, from `ProxyContext.proxy`.
95
+ * Provenance, not a principal — never part of `delegatedFrom` (ADR-0012).
96
+ *
97
+ * Required, and covered by the chain unconditionally (ADR-0019). While it
98
+ * was omitted when absent, an event with no `proxy` and an event whose
99
+ * `proxy` was stripped produced the same preimage, so deleting recorded
100
+ * provenance was not chain-detectable.
101
+ */
102
+ proxy: ProxyIdentity;
103
+ /**
104
+ * Present ONLY on events recorded for a prompt call (`prompts/get`), where
105
+ * `tool` holds the prompt name. An absent `kind` means a tool call, so
106
+ * every event recorded before prompts were audited keeps its meaning and
107
+ * its chain preimage (additive within v2, ADR-0012 and ADR-0014).
108
+ */
109
+ kind?: 'prompt';
110
+ /**
111
+ * Present ONLY on events recorded in erasable mode, where the per-event
112
+ * encryption key and the keyed `inputHash`/`outputHash` derive from a
113
+ * destroyable subject key instead of the signing secret (ADR-0018).
114
+ *
115
+ * Optional and covered by the chain under the ADR-0012 omission rule,
116
+ * exactly as `kind` is: an absent marker means default mode with plain
117
+ * `sha256` hashes, so events recorded before erasable mode existed keep
118
+ * their preimage, and a stored event cannot be silently reinterpreted
119
+ * under the wrong hash scheme.
120
+ */
121
+ erasable?: true;
122
+ tool: string;
123
+ duration_ms: number;
124
+ outcome: 'success' | 'rejected' | 'error';
125
+ /** Present when outcome is 'rejected' (from the MCP error's data field). */
126
+ rejectionReason?: RejectionReason;
127
+ /**
128
+ * Present when outcome is 'error': what the upstream call threw, or a fixed
129
+ * message when the call failed without throwing -- `{ name: 'ToolError' }`
130
+ * for a tool result that carried `isError: true` in-band, and
131
+ * `{ name: 'InvalidToolResult' }` when the call resolved to a value that is
132
+ * not the shape its `kind` expects.
133
+ */
134
+ error?: { name: string; message: string };
135
+ inputHash: string;
136
+ outputHash: string;
137
+ chainHash: string;
138
+ replayManifestPosition: number;
139
+ }
140
+
141
+ export type LowAuditEvent = AuditEventBase & {
142
+ sensitivityTier: 'low';
143
+ inputRaw: Record<string, unknown>;
144
+ outputRaw: unknown;
145
+ };
146
+
147
+ export type MediumAuditEvent = AuditEventBase & {
148
+ sensitivityTier: 'medium';
149
+ inputRaw: Record<string, unknown>;
150
+ outputRaw: unknown;
151
+ };
152
+
153
+ export type HighAuditEvent = AuditEventBase & {
154
+ sensitivityTier: 'high';
155
+ inputEncrypted: string;
156
+ outputEncrypted: string;
157
+ };
158
+
159
+ export type AuditEvent = LowAuditEvent | MediumAuditEvent | HighAuditEvent;
160
+
161
+ // ── Merkle + ReplayManifest ────────────────────────────────────────────────────
162
+
163
+ export interface MerkleProof {
164
+ index: number;
165
+ siblings: string[];
166
+ directions: ('left' | 'right')[];
167
+ }
168
+
169
+ export interface ReplayManifest {
170
+ sessionId: string;
171
+ identity: Identity;
172
+ /**
173
+ * The proxy instance that produced this session's trail, captured when
174
+ * the session was first seen. Covered by the signature like every other
175
+ * field (ADR-0004, ADR-0012).
176
+ *
177
+ * Required, and included in the signing payload unconditionally
178
+ * (ADR-0019), so stripping it from a stored manifest fails the signature
179
+ * rather than rebuilding a payload that verifies.
180
+ */
181
+ proxy: ProxyIdentity;
182
+ startedAt: string;
183
+ closedAt: string;
184
+ eventCount: number;
185
+ merkleRoot: string;
186
+ merkleProofs: MerkleProof[];
187
+ signedBy: string;
188
+ /**
189
+ * HMAC over the canonical serialization of the ENTIRE manifest (every
190
+ * field above, domain-separated) — not just the Merkle root. Verify with
191
+ * `verifyManifestSignature`, which accepts only this exact lowercase hex
192
+ * string: a respelling (uppercase, an appended character) does not verify.
193
+ */
194
+ signature: string;
195
+ }
196
+
197
+ // ── Audit options + handle ─────────────────────────────────────────────────────
198
+
199
+ export interface AuditOptions {
200
+ signingKey: SigningKeyProvider;
201
+ sensitivityResolver: SensitivityResolverFn;
202
+ /**
203
+ * Supply a store to run in ERASABLE mode (ADR-0018). Omit it and the audit
204
+ * layer behaves exactly as it always has, byte for byte: same preimages,
205
+ * same ciphertexts, same plain `sha256` payload hashes, no `erasable`
206
+ * marker on any event.
207
+ *
208
+ * With a store, the erasure unit is the data subject — the resolved
209
+ * `identity.sub` of each event, with anonymous events sharing the
210
+ * `anonymousIdentity()` bucket — and `destroy(sub)` renders that subject's
211
+ * recorded payloads permanently undecryptable and unconfirmable. Chain and
212
+ * manifest verification are unaffected, because payloads are bound to the
213
+ * chain only through their hashes.
214
+ *
215
+ * Key custody becomes your responsibility: see the README's erasable-mode
216
+ * section before choosing a store.
217
+ */
218
+ keyStore?: SubjectKeyStore;
219
+ onEvent: (event: AuditEvent) => void | Promise<void>;
220
+ /**
221
+ * Called with the finished ReplayManifest when the host calls closeSession().
222
+ *
223
+ * Why this exists: ToolMiddleware is a pure per-request function with no
224
+ * lifecycle hooks. Sessions are owned by the HTTP transport, not by
225
+ * middleware. There is no in-band way for middleware to observe session close.
226
+ * This callback gives consumers a push-based way to receive the manifest
227
+ * exactly when the host signals the session has ended — mirroring how audit
228
+ * substrates in financial systems work: the host controls the flush boundary;
229
+ * the audit layer reacts.
230
+ */
231
+ onManifest?: (manifest: ReplayManifest) => void | Promise<void>;
232
+ /**
233
+ * Record events for rejected calls (hidden tools etc.).
234
+ * @default true
235
+ */
236
+ includeRejections?: boolean;
237
+ /**
238
+ * How long `closeSession` waits for calls already admitted to the session
239
+ * to append and persist their events before it seals the manifest. Past
240
+ * the deadline the manifest covers what has landed, and each straggler is
241
+ * reported through `onAuditError` with its tool and requestId. `Infinity`
242
+ * waits without bound. Any other value (NaN, a negative number) is
243
+ * rejected at construction, because it would leave the drain with no
244
+ * deadline at all.
245
+ * @default 30_000
246
+ */
247
+ closeDrainTimeoutMs?: number;
248
+ /**
249
+ * Called when the audit layer itself fails (event serialization, a
250
+ * throwing onEvent sink). The audit layer NEVER throws into the tool-call
251
+ * path; failures are reported here instead. If this hook itself throws or
252
+ * returns a rejected promise, the failure is contained and never reaches
253
+ * the caller either.
254
+ * @default console.error
255
+ */
256
+ onAuditError?: (
257
+ err: unknown,
258
+ info: { tool: string; requestId: string; sessionId?: string },
259
+ ) => unknown;
260
+ }
261
+
262
+ export interface AuditMiddlewareHandle {
263
+ middleware: ToolMiddleware;
264
+ /**
265
+ * Audits `prompts/get` calls. Wire it into
266
+ * `ProxyOptions.promptMiddleware`; it shares the session chain with
267
+ * `middleware`, so tool and prompt events interleave in one trail.
268
+ * Prompt events carry `kind: 'prompt'` (ADR-0014).
269
+ */
270
+ promptMiddleware: PromptMiddleware;
271
+ /**
272
+ * Signal that a session has ended. Waits for every call already admitted
273
+ * to the session to append and persist its event, then computes the
274
+ * Merkle tree over all audit events for the session, signs the full
275
+ * manifest, fires onManifest, and returns the ReplayManifest. A call still
276
+ * inside its handler at teardown therefore lands in the manifest rather
277
+ * than orphaned at position 0. Concurrent calls for the same session share
278
+ * one close. Returns undefined if the session had no events or is unknown.
279
+ *
280
+ * A rejection from the signing provider or from onManifest is retryable:
281
+ * the sealed session is retained, and calling closeSession again with the
282
+ * same id resumes where it stopped. A retry signs the same payload and
283
+ * delivers the same manifest, never a second signed artifact. The state
284
+ * is released only once onManifest has settled successfully.
285
+ */
286
+ closeSession(sessionId: string): Promise<ReplayManifest | undefined>;
287
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import type {
3
+ AuditEvent,
4
+ ReplayManifest,
5
+ SigningKeyProvider,
6
+ } from './types.js';
7
+ import { chainPreimageFields, computeChainHash } from './chain.js';
8
+ import { manifestSigningPayload } from './middleware.js';
9
+
10
+ const DOMAIN_CHAIN = Buffer.from('mcpose/v2/chain');
11
+
12
+ export type ChainVerification =
13
+ { valid: true } | { valid: false; index: number; reason: string };
14
+
15
+ /**
16
+ * Canonical hex: lowercase, even length, hex digits only, non-empty. This
17
+ * is what the producer writes (`digest('hex')`), and the exact string a
18
+ * third-party verifier compares.
19
+ */
20
+ const CANONICAL_HEX = /^(?:[0-9a-f]{2})+$/;
21
+
22
+ /**
23
+ * Compares a stored hex string against the recomputed value in constant
24
+ * time. The stored string must be canonical first: Node's hex decoder drops
25
+ * a trailing odd nibble and stops at the first non-hex character, so
26
+ * `sig + '0'`, `sig + 'z'` and `SIG` all decode to the stored bytes and would
27
+ * otherwise verify. The verifiers authenticate the stored string itself, not
28
+ * just its decoding, so two implementations agree on one artifact (#281).
29
+ */
30
+ function hexEqual(storedHex: string, expectedHex: string): boolean {
31
+ if (!CANONICAL_HEX.test(storedHex)) return false;
32
+ const a = Buffer.from(storedHex, 'hex');
33
+ const b = Buffer.from(expectedHex, 'hex');
34
+ return a.length === b.length && timingSafeEqual(a, b);
35
+ }
36
+
37
+ /**
38
+ * KEYED chain verification: recomputes every event's chainHash from its
39
+ * preimage fields with the chain key derived through the signing oracle.
40
+ * Unlike the keyless `assertAuditChainIntegrity` in @mcpose/testing, this
41
+ * detects key-inconsistent forgeries — a rewritten event, a renumbered
42
+ * suffix, or a chain built under a different secret.
43
+ *
44
+ * An empty event list is invalid: truncation-to-zero must not verify.
45
+ */
46
+ export async function verifyAuditChain(
47
+ events: readonly AuditEvent[],
48
+ signingKey: SigningKeyProvider,
49
+ ): Promise<ChainVerification> {
50
+ if (events.length === 0) {
51
+ return { valid: false, index: 0, reason: 'empty event list' };
52
+ }
53
+
54
+ const chainKey = await signingKey.sign(DOMAIN_CHAIN);
55
+ let prevChainHash = '';
56
+
57
+ for (const [i, event] of events.entries()) {
58
+ if (event.replayManifestPosition !== i) {
59
+ return {
60
+ valid: false,
61
+ index: i,
62
+ reason: `replayManifestPosition ${event.replayManifestPosition}, expected ${i}`,
63
+ };
64
+ }
65
+ const expected = computeChainHash(
66
+ chainPreimageFields(event),
67
+ prevChainHash,
68
+ chainKey,
69
+ );
70
+ if (!hexEqual(event.chainHash, expected)) {
71
+ return { valid: false, index: i, reason: 'chainHash mismatch' };
72
+ }
73
+ prevChainHash = event.chainHash;
74
+ }
75
+
76
+ return { valid: true };
77
+ }
78
+
79
+ /**
80
+ * Recomputes the manifest's signature (which covers every manifest field,
81
+ * not just the Merkle root) and compares it in constant time.
82
+ */
83
+ export async function verifyManifestSignature(
84
+ manifest: ReplayManifest,
85
+ signingKey: SigningKeyProvider,
86
+ ): Promise<boolean> {
87
+ const { signature, ...unsigned } = manifest;
88
+ const expected = await signingKey.sign(
89
+ Buffer.from(manifestSigningPayload(unsigned)),
90
+ );
91
+ return hexEqual(signature, expected.toString('hex'));
92
+ }