Unified · Split
| @@ -57,6 +57,10 @@ describe("inferShape", () => { · 56 unmodified lines | ||
| 57 | 57 | { "12345": { qty: 2 }, "67890": { qty: 1 } }, |
| 58 | 58 | { "https://example.com/page": 3 }, |
| 59 | 59 | { deadbeefdeadbeef00: true }, |
| 60 | + { U012ABCDEF: { presence: "active" } }, | |
| 61 | + { cus_9s6XKzkNRiz8i3: { plan: "pro" } }, | |
| 62 | + { "10.0.0.7": "reachable" }, | |
| 63 | + { sk4bcD3fGh1jKlMnOpQr: true }, | |
| 60 | 64 | ]; |
| 61 | 65 | for (const value of cases) { |
| 62 | 66 | const shape = inferShape(value); |
| @@ -71,13 +75,19 @@ describe("inferShape", () => { · 8 unmodified lines | ||
| 71 | 75 | created_at: "2026-01-01", |
| 72 | 76 | pageUrl: "https://x", |
| 73 | 77 | email2fa: true, |
| 78 | + "@odata.context": "ctx", | |
| 79 | + organizationMembershipSettings: {}, | |
| 80 | + sha256Fingerprint: "…", | |
| 74 | 81 | }); |
| 75 | 82 | expect(shape.properties).toBeDefined(); |
| 76 | 83 | expect(Object.keys(shape.properties ?? {}).sort()).toEqual([ |
| 84 | + "@odata.context", | |
| 77 | 85 | "created_at", |
| 78 | 86 | "email2fa", |
| 79 | 87 | "id", |
| 88 | + "organizationMembershipSettings", | |
| 80 | 89 | "pageUrl", |
| 90 | + "sha256Fingerprint", | |
| 81 | 91 | ]); |
| 82 | 92 | }); |
| 83 | 93 | |
| @@ -55,12 +55,28 @@ const isUnknown = (shape: InferredShape): boolean => · 54 unmodified lines | ||
| 55 | 55 | * collapsing. |
| 56 | 56 | */ |
| 57 | 57 | const DATA_KEY_PATTERNS: readonly RegExp[] = [ |
| 58 | − /@/, | |
| 58 | + // Email addresses (full-string — `@odata.context`-style annotation keys are | |
| 59 | + // legitimate API surface and must NOT collapse). | |
| 60 | + /^[^\s@]+@[^\s@]+\.[^\s@]+$/, | |
| 61 | + // UUIDs. | |
| 59 | 62 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, |
| 63 | + // Timestamps / dates. | |
| 60 | 64 | /^\d{4}-\d{2}-\d{2}/, |
| 65 | + // Bare numbers and IPv4 addresses. | |
| 61 | 66 | /^\d+$/, |
| 67 | + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, | |
| 68 | + // URLs. | |
| 62 | 69 | /^https?:\/\//, |
| 70 | + // Long hex tokens (hashes, ids). | |
| 63 | 71 | /^[0-9a-f]{16,}$/i, |
| 72 | + // Platform opaque ids: Slack-style ALL-CAPS ids (U012ABCDEF), and | |
| 73 | + // prefix_body ids (cus_..., price_..., asst_...). Real field names in | |
| 74 | + // snake_case are lowercase words, not lowercase prefix + mixed-case body. | |
| 75 | + /^[A-Z][A-Z0-9]{8,}$/, | |
| 76 | + /^[a-z]{1,6}_(?=.*[A-Z0-9])[A-Za-z0-9]{10,}$/, | |
| 77 | + // Generic digit-bearing opaque tokens (API keys, base62/base64 ids). Long | |
| 78 | + // camelCase field names rarely contain digits at this length. | |
| 79 | + /^(?=.*\d)[A-Za-z0-9+/=_-]{20,}$/, | |
| 64 | 80 | ]; |
| 65 | 81 | |
| 66 | 82 | const looksLikeDataKey = (key: string): boolean => |
| @@ -27,6 +27,7 @@ const makeStubStorage = () => { · 26 unmodified lines | ||
| 27 | 27 | updatedAt: new Date(0), |
| 28 | 28 | }; |
| 29 | 29 | }; |
| 30 | + let failNextWrites = 0; | |
| 30 | 31 | const unsupported = (member: string) => () => |
| 31 | 32 | Effect.die(`stub storage does not implement ${member}`); |
| 32 | 33 | const storage: PluginStorageFacade = { |
| @@ -43,16 +44,27 @@ const makeStubStorage = () => { · 10 unmodified lines | ||
| 43 | 44 | getForOwner: (input) => Effect.sync(() => entryFor(input.key)), |
| 44 | 45 | list: unsupported("list"), |
| 45 | 46 | put: (input) => |
| 46 | − Effect.sync(() => { | |
| 47 | + Effect.suspend(() => { | |
| 48 | + if (failNextWrites > 0) { | |
| 49 | + failNextWrites -= 1; | |
| 50 | + return Effect.fail({ _tag: "StorageError" as const }) as never; | |
| 51 | + } | |
| 47 | 52 | writes += 1; |
| 48 | 53 | rows.set(input.key, input.data); |
| 49 | − return entryFor(input.key) as never; | |
| 54 | + return Effect.sync(() => entryFor(input.key) as never); | |
| 50 | 55 | }), |
| 51 | 56 | putMany: unsupported("putMany"), |
| 52 | 57 | remove: unsupported("remove"), |
| 53 | 58 | removeMany: unsupported("removeMany"), |
| 54 | 59 | }; |
| 55 | − return { storage, rows, writeCount: () => writes }; | |
| 60 | + return { | |
| 61 | + storage, | |
| 62 | + rows, | |
| 63 | + writeCount: () => writes, | |
| 64 | + failWrites: (count: number) => { | |
| 65 | + failNextWrites = count; | |
| 66 | + }, | |
| 67 | + }; | |
| 56 | 68 | }; |
| 57 | 69 | |
| 58 | 70 | const HOUR = 60 * 60 * 1000; |
| @@ -120,6 +132,24 @@ describe("makeShapeMemory", () => { · 61 unmodified lines | ||
| 120 | 132 | }), |
| 121 | 133 | ); |
| 122 | 134 | |
| 135 | + it.effect("retries after a failed write instead of pretending it persisted", () => | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const stub = makeStubStorage(); | |
| 138 | + const memory = makeShapeMemory(stub.storage); | |
| 139 | + | |
| 140 | + stub.failWrites(1); | |
| 141 | + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 1 }); | |
| 142 | + expect(stub.rows.has(ADDRESS), "the failed write stored nothing").toBe(false); | |
| 143 | + | |
| 144 | + // The very next observation retries — no waiting out the freshness | |
| 145 | + // interval on bookkeeping that lied about persisting. | |
| 146 | + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 2 }); | |
| 147 | + expect(stub.rows.has(ADDRESS), "the retry persisted").toBe(true); | |
| 148 | + const stored = stub.rows.get(ADDRESS) as { observations: number }; | |
| 149 | + expect(stored.observations).toBe(2); | |
| 150 | + }), | |
| 151 | + ); | |
| 152 | + | |
| 123 | 153 | it.effect("treats legacy records without a contract field as direct", () => |
| 124 | 154 | Effect.gen(function* () { |
| 125 | 155 | const stub = makeStubStorage(); |
| @@ -125,9 +125,16 @@ export const makeShapeMemory = (storage: PluginStorageFacade): ShapeMemory => { · 124 unmodified lines | ||
| 125 | 125 | next.observations % OBSERVATION_WRITE_MILESTONE === 0 || |
| 126 | 126 | now - lastWrite >= FRESHNESS_WRITE_INTERVAL_MS; |
| 127 | 127 | if (!shouldWrite) return; |
| 128 | − yield* storage | |
| 128 | + // Advance the persisted-state bookkeeping only on a successful write: | |
| 129 | + // otherwise a transient storage failure would silence retries for the | |
| 130 | + // whole freshness interval while nothing is actually stored. | |
| 131 | + const wrote = yield* storage | |
| 129 | 132 | .put({ owner, collection: COLLECTION, key: address, data: next }) |
| 130 | − .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 133 | + .pipe( | |
| 134 | + Effect.map(() => true), | |
| 135 | + Effect.catch(() => Effect.succeed(false)), | |
| 136 | + ); | |
| 137 | + if (!wrote) return; | |
| 131 | 138 | persistedSchema.set(key, schemaJson); |
| 132 | 139 | persistedAt.set(key, now); |
| 133 | 140 | }).pipe(Effect.catchCause(() => Effect.void)); |
| @@ -86,6 +86,22 @@ describe("normalizeMcpCallToolResult", () => { · 85 unmodified lines | ||
| 86 | 86 | expect(normalized.meta).toEqual({ "io.modelcontextprotocol/serverInfo": { name: "s" } }); |
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | + it("survives adversarially deep valid JSON without blowing the stack", () => { | |
| 90 | + const deep = "[".repeat(50_000) + "]".repeat(50_000); | |
| 91 | + // Deep duplicate-detection degrades to "not a duplicate" (block kept); | |
| 92 | + // it must never throw from inside the invocation path. | |
| 93 | + const normalized = normalizeMcpCallToolResult({ | |
| 94 | + content: [text(deep)], | |
| 95 | + structuredContent: { safe: true }, | |
| 96 | + }); | |
| 97 | + expect(normalized.data).toEqual({ safe: true }); | |
| 98 | + expect(normalized.content).toEqual([text(deep)]); | |
| 99 | + // The lone-text parse path is equally safe: a stack-exceeding parse is | |
| 100 | + // simply "not JSON" and the text stays a string. | |
| 101 | + const lone = normalizeMcpCallToolResult({ content: [text(deep)] }); | |
| 102 | + expect(typeof lone.data === "string" || Array.isArray(lone.data)).toBe(true); | |
| 103 | + }); | |
| 104 | + | |
| 89 | 105 | it("passes non-envelope values through untouched", () => { |
| 90 | 106 | expect(normalizeMcpCallToolResult({ rows: [1] }).data).toEqual({ rows: [1] }); |
| 91 | 107 | expect(normalizeMcpCallToolResult("plain").data).toBe("plain"); |
| @@ -77,21 +77,30 @@ const parseJsonPayload = (text: string): unknown | undefined => { · 76 unmodified lines | ||
| 77 | 77 | } |
| 78 | 78 | }; |
| 79 | 79 | |
| 80 | +/** Depth bound for duplicate detection. Beyond it two values are treated as | |
| 81 | + * DIFFERENT — the safe direction: the block is kept rather than dropped — | |
| 82 | + * and, critically, an adversarially deep (but valid) upstream JSON cannot | |
| 83 | + * blow the stack inside the invocation path. */ | |
| 84 | +const MAX_EQUALITY_DEPTH = 64; | |
| 85 | + | |
| 80 | 86 | /** Structural equality against the parsed form of a text block, used to |
| 81 | 87 | * suppress the spec-mandated serialized duplicate of `structuredContent`. |
| 82 | − * Key-order insensitive; bounded by the same size guard as payload parsing. */ | |
| 83 | −const structurallyEquals = (left: unknown, right: unknown): boolean => { | |
| 88 | + * Key-order insensitive; depth-bounded. */ | |
| 89 | +const structurallyEquals = (left: unknown, right: unknown, depth = 0): boolean => { | |
| 84 | 90 | if (Object.is(left, right)) return true; |
| 91 | + if (depth >= MAX_EQUALITY_DEPTH) return false; | |
| 85 | 92 | if (Array.isArray(left) && Array.isArray(right)) { |
| 86 | 93 | return ( |
| 87 | 94 | left.length === right.length && |
| 88 | − left.every((item, index) => structurallyEquals(item, right[index])) | |
| 95 | + left.every((item, index) => structurallyEquals(item, right[index], depth + 1)) | |
| 89 | 96 | ); |
| 90 | 97 | } |
| 91 | 98 | if (isRecord(left) && isRecord(right)) { |
| 92 | 99 | const leftKeys = Object.keys(left); |
| 93 | 100 | if (leftKeys.length !== Object.keys(right).length) return false; |
| 94 | − return leftKeys.every((key) => key in right && structurallyEquals(left[key], right[key])); | |
| 101 | + return leftKeys.every( | |
| 102 | + (key) => key in right && structurallyEquals(left[key], right[key], depth + 1), | |
| 103 | + ); | |
| 95 | 104 | } |
| 96 | 105 | return false; |
| 97 | 106 | }; |