From cf5898205aad06641b696501578e9cd36add4b7f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:02:36 -0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20CCR=20cross-tenant=20IDOR=20?= =?UTF-8?q?=E2=80=94=20scope=20store=20per-principal=20+=20bound=20memory?= =?UTF-8?q?=20(#3859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.25 — fix(security): CCR cross-tenant IDOR (scope store per-principal + bound memory). --- file-size-baseline.json | 2 +- open-sse/handlers/chatCore.ts | 1 + open-sse/mcp-server/tools/compressionTools.ts | 8 +- .../services/compression/engines/ccr/index.ts | 151 +++++++--- .../services/compression/engines/types.ts | 2 + .../services/compression/strategySelector.ts | 17 +- .../unit/compression/ccr-cross-tenant.test.ts | 268 ++++++++++++++++++ 7 files changed, 401 insertions(+), 48 deletions(-) create mode 100644 tests/unit/compression/ccr-cross-tenant.test.ts diff --git a/file-size-baseline.json b/file-size-baseline.json index de0bb16263..c20eeccadd 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -15,7 +15,7 @@ "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 868, "open-sse/handlers/audioSpeech.ts": 952, - "open-sse/handlers/chatCore.ts": 5811, + "open-sse/handlers/chatCore.ts": 5812, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1442, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 835b840522..1e37799084 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2626,6 +2626,7 @@ export async function handleChatCore({ const result = await applyCompressionAsync(compressionInputBody, mode, { model: effectiveModel, config, + principalId: apiKeyInfo?.id ? String(apiKeyInfo.id) : undefined, }); if (result.stats) { if (result.compressed) { diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 198af4d333..3e95e73137 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -243,6 +243,7 @@ import { compressionComboStatsInput, } from "../schemas/tools.ts"; import { handleCcrRetrieve } from "../../services/compression/engines/ccr/index.ts"; +import { resolveCallerScopeContext } from "../scopeEnforcement.ts"; const ccrRetrieveInput = z.object({ hash: z @@ -351,6 +352,11 @@ export const compressionTools = { "Scope: read:compression. Always available (sticky-on).", scopes: ["read:compression"], inputSchema: ccrRetrieveInput, - handler: (args: z.infer) => handleCcrRetrieve(args), + handler: (args: z.infer, extra?: McpToolExtraLike) => { + // Derive caller identity from MCP auth context so the retrieve is scoped to the + // same principal that stored the block. This closes the cross-tenant IDOR (HIGH). + const { callerId } = resolveCallerScopeContext(extra, ["read:compression"]); + return handleCcrRetrieve(args, callerId === "anonymous" ? undefined : callerId); + }, }, }; diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index cc73d3e3f0..704cac9f03 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -4,21 +4,30 @@ * Replaces large contiguous blocks of text with a content-addressed * retrieve marker: `[CCR retrieve hash=<24hex> chars=]` * - * The verbatim block is stored in an in-module content-addressed store - * (keyed by 24-hex content hash). The `retrieve` MCP tool (or the - * `handleCcrRetrieve` helper exported here) returns the block on demand. + * The verbatim block is stored in a principal-scoped, bounded in-module store. + * The store key is `${principalId ?? "__anon__"} ${contentHash}` so that one + * principal cannot read another's stored blocks (IDOR protection). + * + * The `retrieve` MCP tool (or the `handleCcrRetrieve` helper exported here) + * returns the block on demand when called with the matching callerId. * * Algorithm: * - Scan non-system messages; for each `type:"text"` part or string content, * find contiguous text blocks ≥ minChars characters. * - Replace the block with `[CCR retrieve hash=<24hex> chars=]` only if * the marker is shorter than the original block. - * - Store the original block keyed by hash in the CCR store. + * - Store the original block keyed by (principalId, hash) in the CCR store. * - * Feedback: - * - `recordRetrieval(hash)` increments a retrieval counter for that hash. - * - `shouldSkipCompression(hash)` returns true once the counter reaches - * RETRIEVAL_THRESHOLD, signalling "do not compress this block again". + * Feedback (scoped by principal): + * - `recordRetrieval(hash, principalId)` increments a retrieval counter for + * that (principalId, hash) pair. + * - `shouldSkipCompression(hash, principalId)` returns true once the counter + * reaches RETRIEVAL_THRESHOLD for that principal — one principal's behaviour + * does not affect another's (cross-tenant state drift protection). + * + * Memory bound: + * - Both `ccrStore` and `retrievalCounts` are capped at MAX_CCR_ENTRIES + * entries using FIFO eviction (Map insertion-order guarantees). * * Conservative guards: * - Never touch `role: "system"`. @@ -42,57 +51,95 @@ import type { CompressionResult } from "../../types.ts"; const ENGINE_ID = "ccr"; /** Default minimum character count for a block to be a CCR candidate. */ const DEFAULT_MIN_CHARS = 600; -/** Number of retrievals before a block is flagged "do-not-compress". */ +/** Number of retrievals before a block is flagged "do-not-compress" for that principal. */ const RETRIEVAL_THRESHOLD = 3; /** Regex to match CCR markers for reconstruction. */ const MARKER_RE = /\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/g; +/** + * Maximum number of entries in each bounded store. + * When inserting beyond this cap, the oldest entry (Map insertion order) is evicted. + * 5 000 entries × ~2 KB average ≈ 10 MB upper bound for each map. + */ +export const MAX_CCR_ENTRIES = 5_000; -// ─── content-addressed store ────────────────────────────────────────────────── +// ─── principal-scoped, bounded content store ────────────────────────────────── -/** Map from 24-hex hash → verbatim block text. */ +/** + * Store key = `${principalId ?? "__anon__"} ${contentHash}`. + * Using a compound key scopes data to the principal that stored it. + */ const ccrStore = new Map(); -/** Map from 24-hex hash → retrieval count (feedback signal). */ +/** Retrieval counter store — same scoping as ccrStore. */ const retrievalCounts = new Map(); +/** Sentinel used when no principalId is provided. */ +const ANON = "__anon__"; + +function buildStoreKey(hash: string, principalId?: string): string { + return `${principalId ?? ANON} ${hash}`; +} + +/** + * Insert a value into a bounded Map, evicting the oldest entry when over the cap. + */ +function boundedSet(map: Map, key: string, value: V): void { + if (!map.has(key) && map.size >= MAX_CCR_ENTRIES) { + // Map preserves insertion order — the first iterator result is the oldest entry. + const firstKey = map.keys().next().value; + if (firstKey !== undefined) { + map.delete(firstKey); + } + } + map.set(key, value); +} + /** * Compute a 24-hex content hash for a text block (SHA-256 prefix). + * This is the hash embedded in the marker; principal scoping is internal to + * the store key and is NOT part of the marker itself. */ function hashContent(text: string): string { return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24); } /** - * Store a block in the CCR store, returning its hash. + * Store a block in the CCR store under the given principal. + * Returns the 24-hex content hash (for embedding in the marker). */ -function storeBlock(text: string): string { +export function storeBlock(text: string, principalId?: string): string { const hash = hashContent(text); - if (!ccrStore.has(hash)) { - ccrStore.set(hash, text); + const key = buildStoreKey(hash, principalId); + if (!ccrStore.has(key)) { + boundedSet(ccrStore, key, text); } return hash; } /** - * Retrieve the verbatim block for a given hash. - * Returns null if not found. + * Retrieve the verbatim block for a given hash and principal. + * Returns null if not found or if the principal does not match the stored key. */ -export function retrieveBlock(hash: string): string | null { - return ccrStore.get(hash) ?? null; +export function retrieveBlock(hash: string, principalId?: string): string | null { + const key = buildStoreKey(hash, principalId); + return ccrStore.get(key) ?? null; } /** - * Record a retrieval event for a given hash (feedback signal). + * Record a retrieval event for a given (hash, principal) pair (feedback signal). */ -export function recordRetrieval(hash: string): void { - retrievalCounts.set(hash, (retrievalCounts.get(hash) ?? 0) + 1); +export function recordRetrieval(hash: string, principalId?: string): void { + const key = buildStoreKey(hash, principalId); + boundedSet(retrievalCounts, key, (retrievalCounts.get(key) ?? 0) + 1); } /** - * Returns true if the block has been retrieved often enough that it - * should be excluded from compression in future requests. + * Returns true if the block has been retrieved often enough by this principal + * that it should be excluded from compression in future requests. + * Each principal's feedback is isolated from other principals. */ -export function shouldSkipCompression(hash: string): boolean { - return (retrievalCounts.get(hash) ?? 0) >= RETRIEVAL_THRESHOLD; +export function shouldSkipCompression(hash: string, principalId?: string): boolean { + const key = buildStoreKey(hash, principalId); + return (retrievalCounts.get(key) ?? 0) >= RETRIEVAL_THRESHOLD; } /** @@ -107,21 +154,29 @@ export function resetCcrStore(): void { /** * Handler for the `omniroute_ccr_retrieve` MCP tool. + * + * The `callerId` parameter must be the authenticated principal id derived from + * the MCP `extra` context (see compressionTools.ts). Only the principal that + * stored the block can retrieve it. + * * Returns the verbatim block for the given hash, or an error object. */ -export function handleCcrRetrieve(args: { hash: string }): { content: string } | { error: string } { +export function handleCcrRetrieve( + args: { hash: string }, + callerId?: string +): { content: string } | { error: string } { if (!args.hash || typeof args.hash !== "string") { return { error: "hash parameter is required and must be a string" }; } - const block = retrieveBlock(args.hash); + const block = retrieveBlock(args.hash, callerId); if (block === null) { return { error: `CCR block not found for hash=${args.hash}. The block may have expired or the hash is invalid.`, }; } - recordRetrieval(args.hash); + recordRetrieval(args.hash, callerId); return { content: block }; } @@ -146,7 +201,8 @@ function buildMarker(hash: string, charCount: number): string { */ function maybeCcrReplace( text: string, - minChars: number + minChars: number, + principalId?: string ): { text: string; replaced: boolean; hash: string | null } { if (text.length < minChars) { return { text, replaced: false, hash: null }; @@ -154,8 +210,8 @@ function maybeCcrReplace( const hash = hashContent(text); - // Skip if this hash is flagged as do-not-compress - if (shouldSkipCompression(hash)) { + // Skip if this (principal, hash) pair is flagged as do-not-compress + if (shouldSkipCompression(hash, principalId)) { return { text, replaced: false, hash: null }; } @@ -166,7 +222,7 @@ function maybeCcrReplace( return { text, replaced: false, hash: null }; } - storeBlock(text); + storeBlock(text, principalId); return { text: marker, replaced: true, hash }; } @@ -175,7 +231,8 @@ function maybeCcrReplace( */ function processMessages( messages: MessageLike[], - minChars: number + minChars: number, + principalId?: string ): { messages: MessageLike[]; replacedCount: number } { let replacedCount = 0; @@ -183,7 +240,7 @@ function processMessages( if (msg.role === "system") return { ...msg }; if (typeof msg.content === "string") { - const { text, replaced } = maybeCcrReplace(msg.content, minChars); + const { text, replaced } = maybeCcrReplace(msg.content, minChars, principalId); if (replaced) { replacedCount++; return { ...msg, content: text }; @@ -195,7 +252,7 @@ function processMessages( let changed = false; const newContent = msg.content.map((part) => { if (part["type"] !== "text" || typeof part["text"] !== "string") return part; - const { text, replaced } = maybeCcrReplace(part["text"] as string, minChars); + const { text, replaced } = maybeCcrReplace(part["text"] as string, minChars, principalId); if (replaced) { changed = true; replacedCount++; @@ -253,11 +310,15 @@ function validateCcrConfig(config: Record): EngineValidationRes /** * Reconstruct a body by replacing all `[CCR retrieve hash=<24hex> chars=N]` - * markers with the stored verbatim blocks. + * markers with the stored verbatim blocks for the given principal. * * Returns a new body object with markers restored to their original text. + * Markers that do not belong to the given principal remain unchanged. */ -export function reconstructCcr(body: Record): Record { +export function reconstructCcr( + body: Record, + principalId?: string +): Record { const messages = body["messages"]; if (!Array.isArray(messages)) return body; @@ -272,7 +333,7 @@ export function reconstructCcr(body: Record): Record { - return ccrStore.get(hash) ?? _m; + return retrieveBlock(hash, principalId) ?? _m; }); return reconstructed !== content ? { ...msg, content: reconstructed } : { ...msg }; } @@ -282,7 +343,7 @@ export function reconstructCcr(body: Record): Record { if (part["type"] !== "text" || typeof part["text"] !== "string") return part; const reconstructed = (part["text"] as string).replace(MARKER_RE, (_m, hash: string) => { - return ccrStore.get(hash) ?? _m; + return retrieveBlock(hash, principalId) ?? _m; }); if (reconstructed !== part["text"]) { changed = true; @@ -307,7 +368,8 @@ export const ccrEngine: CompressionEngine = { description: "Replaces large blocks of text with content-addressed retrieve markers " + "`[CCR retrieve hash=<24hex> chars=N]`. The original block is stored and " + - "retrievable via the `omniroute_ccr_retrieve` MCP tool (H4).", + "retrievable via the `omniroute_ccr_retrieve` MCP tool (H4). " + + "Store is principal-scoped: only the storing principal can retrieve their blocks.", icon: "archive", targets: ["messages"], stackable: true, @@ -319,7 +381,7 @@ export const ccrEngine: CompressionEngine = { name: "CCR (Content-Compression-Retrieve)", description: "Reversible compression: large blocks → retrieve marker. " + - "Original retrievable via MCP tool (H4).", + "Original retrievable via MCP tool (H4). Principal-scoped for tenant isolation.", inputScope: "messages", targetLatencyMs: 1, supportsPreview: true, @@ -346,7 +408,8 @@ export const ccrEngine: CompressionEngine = { const start = performance.now(); const { messages: newMessages, replacedCount } = processMessages( messages as MessageLike[], - minChars + minChars, + options?.principalId ); if (replacedCount === 0) { diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts index 6412be9e1b..02cb601023 100644 --- a/open-sse/services/compression/engines/types.ts +++ b/open-sse/services/compression/engines/types.ts @@ -35,6 +35,8 @@ export interface CompressionEngineApplyOptions { config?: CompressionConfig; compressionComboId?: string | null; stepConfig?: Record; + /** Authenticated principal (API key id) making the request. Used by CCR to scope its store. */ + principalId?: string; } export interface CompressionEngine { diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index c02972f231..105e3da4df 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -70,7 +70,12 @@ export function selectCompressionStrategy( export function applyCompression( body: Record, mode: CompressionMode, - options?: { model?: string; supportsVision?: boolean | null; config?: CompressionConfig } + options?: { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + principalId?: string; + } ): CompressionResult { if (mode === "off") { return { body, compressed: false, stats: null }; @@ -190,7 +195,12 @@ export function applyCompression( export async function applyCompressionAsync( body: Record, mode: CompressionMode, - options?: { model?: string; supportsVision?: boolean | null; config?: CompressionConfig } + options?: { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + principalId?: string; + } ): Promise { if (mode === "stacked") { const adapter = adaptBodyForCompression(body); @@ -231,6 +241,8 @@ interface StackOptions { compressionComboId?: string | null; /** TV1 bail-out discipline (opt-in, default disabled). */ bailout?: BailoutConfig; + /** Authenticated principal id — threaded through to CCR engine for store scoping. */ + principalId?: string; } /** Accumulates per-step telemetry across a stacked run (shared sync/async). */ @@ -274,6 +286,7 @@ function buildStepOptions( return { ...options, compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId, + principalId: options?.principalId, stepConfig: { ...(step.config ?? {}), ...(step.intensity ? { intensity: step.intensity } : {}), diff --git a/tests/unit/compression/ccr-cross-tenant.test.ts b/tests/unit/compression/ccr-cross-tenant.test.ts new file mode 100644 index 0000000000..c736177256 --- /dev/null +++ b/tests/unit/compression/ccr-cross-tenant.test.ts @@ -0,0 +1,268 @@ +/** + * TDD security tests: CCR cross-tenant IDOR + bounded memory + scoped feedback. + * Run: node --import tsx/esm --test tests/unit/compression/ccr-cross-tenant.test.ts + * + * RED → GREEN: these tests define the security contract and must drive the fix. + * + * Security assertions: + * 1. Cross-tenant IDOR blocked: principal B cannot retrieve a block stored by A. + * 2. Anonymous principal cannot retrieve a block stored by a named principal. + * 3. Memory bound: store never exceeds MAX_CCR_ENTRIES (FIFO eviction). + * 4. Scoped feedback: A's retrievals do NOT flip B's shouldSkipCompression. + * 5. handleCcrRetrieve end-to-end: cross-tenant retrieve returns not-found error. + */ + +import crypto from "node:crypto"; +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + storeBlock, + retrieveBlock, + recordRetrieval, + shouldSkipCompression, + resetCcrStore, + handleCcrRetrieve, + ccrEngine, + MAX_CCR_ENTRIES, +} from "../../../open-sse/services/compression/engines/ccr/index.ts"; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeText(seed: string, length: number = 700): string { + return seed.repeat(Math.ceil(length / seed.length)).slice(0, length); +} + +const TEXT_A = makeText("content for principal A — confidential data"); +const TEXT_B = makeText("content for principal B — different tenant"); + +function contentHash(text: string): string { + return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24); +} + +// ─── cross-tenant IDOR isolation ───────────────────────────────────────────── + +describe("ccr security: cross-tenant IDOR isolation", () => { + beforeEach(() => resetCcrStore()); + + it("[HIGH] principal B cannot retrieve a block stored by principal A", () => { + const hash = storeBlock(TEXT_A, "principalA"); + + // Same principal can retrieve + const resultA = retrieveBlock(hash, "principalA"); + assert.equal(resultA, TEXT_A, "principal A must be able to retrieve its own block"); + + // Different principal is blocked — THE CORE SECURITY ASSERTION + const resultB = retrieveBlock(hash, "principalB"); + assert.equal( + resultB, + null, + "[HIGH IDOR] principal B must NOT be able to retrieve principal A's block" + ); + }); + + it("[HIGH] anonymous principal cannot retrieve a block stored by a named principal", () => { + const hash = storeBlock(TEXT_A, "principalA"); + + const resultAnon = retrieveBlock(hash, undefined); + assert.equal( + resultAnon, + null, + "[HIGH IDOR] anonymous principal must NOT retrieve a named principal's block" + ); + }); + + it("named principal cannot retrieve a block stored by anonymous", () => { + const hash = storeBlock(TEXT_B, undefined); + + const resultA = retrieveBlock(hash, "principalA"); + assert.equal(resultA, null, "principal A must NOT retrieve an anonymous block"); + + // The anonymous store owner can retrieve + const resultAnon = retrieveBlock(hash, undefined); + assert.equal(resultAnon, TEXT_B, "anonymous can retrieve its own block"); + }); + + it("principal with no stored block cannot retrieve via another principal's hash", () => { + // Store TEXT_A only under principalA — principalB never stores anything. + const hashA = storeBlock(TEXT_A, "principalA"); + + // principalA can retrieve their own block + assert.equal(retrieveBlock(hashA, "principalA"), TEXT_A, "A can retrieve its own block"); + + // principalB never stored anything — cannot retrieve even with the correct hash + assert.equal( + retrieveBlock(hashA, "principalB"), + null, + "[HIGH IDOR] B (never stored) cannot retrieve A's block by reusing A's hash" + ); + + // anonymous similarly cannot + assert.equal( + retrieveBlock(hashA, undefined), + null, + "[HIGH IDOR] anonymous cannot retrieve A's block" + ); + }); +}); + +// ─── handleCcrRetrieve end-to-end isolation ─────────────────────────────────── + +describe("ccr security: handleCcrRetrieve end-to-end isolation", () => { + beforeEach(() => resetCcrStore()); + + it("handleCcrRetrieve with correct callerId returns content", () => { + const hash = storeBlock(TEXT_A, "callerA"); + const result = handleCcrRetrieve({ hash }, "callerA"); + assert.ok("content" in result, "same-caller retrieve must return content"); + assert.equal((result as { content: string }).content, TEXT_A); + }); + + it("[HIGH] handleCcrRetrieve with wrong callerId returns not-found error", () => { + const hash = storeBlock(TEXT_A, "callerA"); + const result = handleCcrRetrieve({ hash }, "callerB"); + assert.ok( + "error" in result, + "[HIGH IDOR] cross-tenant retrieve via handleCcrRetrieve must return error, not content" + ); + assert.ok(!("content" in result), "[HIGH IDOR] cross-tenant retrieve must NOT return content"); + }); + + it("handleCcrRetrieve without callerId cannot access named-principal block", () => { + const hash = storeBlock(TEXT_A, "callerA"); + const result = handleCcrRetrieve({ hash }, undefined); + assert.ok("error" in result, "anonymous retrieve of named-principal block must return error"); + }); + + it("handleCcrRetrieve returns error for completely unknown hash", () => { + const result = handleCcrRetrieve({ hash: "000000000000000000000000" }, "anyPrincipal"); + assert.ok("error" in result, "unknown hash must return error"); + assert.ok(typeof (result as { error: string }).error === "string"); + }); + + it("handleCcrRetrieve returns error for missing hash parameter", () => { + // @ts-expect-error — intentional wrong call to verify guard + const result = handleCcrRetrieve({}, "caller"); + assert.ok("error" in result, "missing hash must return error"); + }); +}); + +// ─── scoped retrieval feedback ──────────────────────────────────────────────── + +describe("ccr security: [MEDIUM] scoped retrieval feedback (shouldSkipCompression)", () => { + beforeEach(() => resetCcrStore()); + + it("A's retrievals do NOT flip B's shouldSkipCompression", () => { + const hash = storeBlock(TEXT_A, "principalA"); + storeBlock(TEXT_A, "principalB"); // same content stored under B + + // Record retrievals for A above the threshold (default 3) + recordRetrieval(hash, "principalA"); + recordRetrieval(hash, "principalA"); + recordRetrieval(hash, "principalA"); + + // A's compression should be skipped + assert.equal( + shouldSkipCompression(hash, "principalA"), + true, + "A's compression should be skipped after threshold retrievals" + ); + + // B's compression must NOT be affected + assert.equal( + shouldSkipCompression(hash, "principalB"), + false, + "[MEDIUM] A's retrievals must NOT flip B's shouldSkipCompression" + ); + }); + + it("shouldSkipCompression returns false for a principal with no retrievals", () => { + const hash = storeBlock(TEXT_A, "principalA"); + assert.equal(shouldSkipCompression(hash, "principalA"), false); + assert.equal(shouldSkipCompression(hash, "principalB"), false); + assert.equal(shouldSkipCompression(hash, undefined), false); + }); +}); + +// ─── bounded memory (FIFO eviction) ────────────────────────────────────────── + +describe("ccr security: [MEDIUM] bounded memory (FIFO eviction)", () => { + beforeEach(() => resetCcrStore()); + + it("MAX_CCR_ENTRIES is exported and positive", () => { + assert.ok(typeof MAX_CCR_ENTRIES === "number", "MAX_CCR_ENTRIES must be a number"); + assert.ok(MAX_CCR_ENTRIES > 0, "MAX_CCR_ENTRIES must be positive"); + }); + + it("FIFO eviction: oldest entry is evicted when cap is reached", () => { + const principal = "evictionTest"; + + // Fill to exactly cap with unique blocks + const firstText = `eviction block 0 ${"y".repeat(30)}`; + const firstHash = storeBlock(firstText, principal); + + for (let i = 1; i < MAX_CCR_ENTRIES; i++) { + storeBlock(`eviction block ${i} ${"y".repeat(30)}`, principal); + } + + // Confirm first block is present before overflow + assert.equal( + retrieveBlock(firstHash, principal), + firstText, + "first block must be present at exactly-cap state" + ); + + // Insert one more unique block — should evict the first (oldest) entry + storeBlock(`eviction overflow block ${"z".repeat(50)}`, principal); + + // First block should now be gone + assert.equal( + retrieveBlock(firstHash, principal), + null, + "[MEDIUM] FIFO eviction: oldest block must be evicted when cap is reached" + ); + }); + + it("store does not grow unboundedly beyond MAX_CCR_ENTRIES distinct principals", () => { + // Insert MAX+5 distinct (principal, content) pairs and verify the overall store + // did not go unbounded by checking that the first inserted block is evicted. + const firstText = `overflow test block 0 ${"a".repeat(40)}`; + const firstPrincipal = "overflow-p-0"; + const firstHash = storeBlock(firstText, firstPrincipal); + + for (let i = 1; i < MAX_CCR_ENTRIES + 5; i++) { + storeBlock(`overflow test block ${i} ${"a".repeat(40)}`, `overflow-p-${i}`); + } + + // The first inserted store-key should have been evicted by FIFO + assert.equal( + retrieveBlock(firstHash, firstPrincipal), + null, + "[MEDIUM] unbounded-growth prevented: first inserted entry must be evicted after MAX+5 inserts" + ); + }); +}); + +// ─── engine-level scoping: apply() threads principalId end-to-end ───────────── + +describe("ccr security: [HIGH] ccrEngine.apply scopes the stored block to the principal", () => { + beforeEach(() => resetCcrStore()); + + const bigBlock = makeText("a large block that CCR would normally compress ", 5000); + const makeBody = () => ({ messages: [{ role: "user", content: bigBlock }] }); + + it("apply with a principalId stores the block retrievable ONLY by that principal", () => { + const result = ccrEngine.apply(makeBody(), { + principalId: "principalA", + stepConfig: { minChars: 100 }, + }); + assert.equal(result.compressed, true, "CCR compresses the large block"); + const hash = contentHash(bigBlock); + assert.equal(retrieveBlock(hash, "principalA"), bigBlock, "owner principal A can retrieve"); + assert.equal( + retrieveBlock(hash, "principalB"), + null, + "[HIGH IDOR] principal B must NOT retrieve principal A's apply()-stored block" + ); + }); +});