mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(compression): risk-gate pre-pass — shield sensitive spans from lossy compression (#5243)
Risk-gate pre-pass — shields sensitive spans (PEM/secret/stack/k8s/migration/legal) from lossy compression via SENTINEL preserveSpans. Default off, fail-open, ReDoS-bounded patterns. strategySelector baseline rebaselined for the wrapper extraction.
This commit is contained in:
committed by
GitHub
parent
0568c37eee
commit
462bca68b2
@@ -161,7 +161,8 @@
|
||||
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
|
||||
"open-sse/services/combo.ts": 3368,
|
||||
"_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).",
|
||||
"open-sse/services/compression/strategySelector.ts": 854,
|
||||
"_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
|
||||
"open-sse/services/compression/strategySelector.ts": 899,
|
||||
"open-sse/services/rateLimitManager.ts": 1035,
|
||||
"open-sse/services/tokenRefresh.ts": 2103,
|
||||
"open-sse/services/usage.ts": 3454,
|
||||
|
||||
Binary file not shown.
3
open-sse/services/compression/riskGate/index.ts
Normal file
3
open-sse/services/compression/riskGate/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { detectRiskSpans, type RiskGateConfig, type RiskSpan } from "./riskGate.ts";
|
||||
export { applyRiskMask, restoreRiskBlocks, type RiskGateStats, type RiskMaskResult } from "./riskGateStep.ts";
|
||||
export type { RiskCategory } from "./riskPatterns.ts";
|
||||
136
open-sse/services/compression/riskGate/riskGate.ts
Normal file
136
open-sse/services/compression/riskGate/riskGate.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { RISK_PATTERNS, type RiskCategory } from "./riskPatterns.ts";
|
||||
|
||||
export interface RiskGateConfig {
|
||||
enabled: boolean;
|
||||
/** Subset of categories to scan for. Absent/empty ⇒ all categories. */
|
||||
categories?: RiskCategory[];
|
||||
}
|
||||
|
||||
export interface RiskSpan {
|
||||
start: number; // inclusive char offset
|
||||
end: number; // exclusive
|
||||
category: RiskCategory;
|
||||
}
|
||||
|
||||
interface Hit {
|
||||
start: number;
|
||||
end: number;
|
||||
category: RiskCategory;
|
||||
}
|
||||
|
||||
const SHORT_SECTION = 200;
|
||||
const MIN_DDL = 2;
|
||||
const VCS_LINE = /^(?:commit [0-9a-f]{7,40}|diff --git |@@ |[+-]{3} )/m;
|
||||
const DIFF_HUNK_LINE = /^[+-]/;
|
||||
|
||||
function isLikelyVcsContext(text: string): boolean {
|
||||
return VCS_LINE.test(text);
|
||||
}
|
||||
|
||||
/** Collect raw regex hits for enabled categories (no guards yet). */
|
||||
function collectRegexHits(text: string, enabled: Set<RiskCategory>): Hit[] {
|
||||
const hits: Hit[] = [];
|
||||
for (const { category, regex } of RISK_PATTERNS) {
|
||||
if (!enabled.has(category)) continue;
|
||||
regex.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = regex.exec(text)) !== null) {
|
||||
if (m[0].length === 0) {
|
||||
regex.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
hits.push({ start: m.index, end: m.index + m[0].length, category });
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** True when the hit's line starts with a diff marker (`+`/`-`). */
|
||||
function inDiffHunk(text: string, start: number): boolean {
|
||||
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
||||
return DIFF_HUNK_LINE.test(text.slice(lineStart, lineStart + 1));
|
||||
}
|
||||
|
||||
/** Structural k8s Secret detector: a YAML doc carrying `kind: Secret` + `data:`. */
|
||||
function detectK8sSecret(text: string): Hit[] {
|
||||
const hits: Hit[] = [];
|
||||
const kindRe = /^kind:[ \t]*Secret\b/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = kindRe.exec(text)) !== null) {
|
||||
// Document boundaries: nearest `---`/start before, nearest `---`/end after.
|
||||
const prevSep = text.lastIndexOf("\n---", m.index);
|
||||
const docStart = prevSep === -1 ? 0 : prevSep + 1;
|
||||
const nextSep = text.indexOf("\n---", m.index);
|
||||
const docEnd = nextSep === -1 ? text.length : nextSep + 1;
|
||||
const doc = text.slice(docStart, docEnd);
|
||||
if (/^\s*(?:data|stringData):/m.test(doc)) {
|
||||
hits.push({ start: docStart, end: docEnd, category: "k8s_secret" });
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function mergeSpans(spans: RiskSpan[]): RiskSpan[] {
|
||||
if (spans.length <= 1) return spans;
|
||||
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
||||
const out: RiskSpan[] = [sorted[0]];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = out[out.length - 1];
|
||||
const cur = sorted[i];
|
||||
if (cur.start <= last.end) {
|
||||
last.end = Math.max(last.end, cur.end);
|
||||
} else {
|
||||
out.push(cur);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect spans that should be shielded from compression. Pure and fail-open:
|
||||
* any internal error yields an empty result (never throws).
|
||||
*/
|
||||
export function detectRiskSpans(text: string, cfg: RiskGateConfig): RiskSpan[] {
|
||||
try {
|
||||
if (!cfg.enabled) return [];
|
||||
if (!text) return [];
|
||||
const enabled = new Set<RiskCategory>(
|
||||
cfg.categories?.length
|
||||
? cfg.categories
|
||||
: (["stack_trace", "private_key", "secret_assignment", "k8s_secret", "db_migration", "legal"] as RiskCategory[])
|
||||
);
|
||||
const vcs = isLikelyVcsContext(text);
|
||||
|
||||
const regexHits = collectRegexHits(text, enabled);
|
||||
const k8sHits = enabled.has("k8s_secret") ? detectK8sSecret(text) : [];
|
||||
|
||||
// db_migration: require >=MIN_DDL hits; drop those inside a diff hunk.
|
||||
const ddl = regexHits.filter((h) => h.category === "db_migration" && !(vcs && inDiffHunk(text, h.start)));
|
||||
const ddlPromoted: RiskSpan[] =
|
||||
ddl.length >= MIN_DDL ? [{ start: ddl[0].start, end: ddl[ddl.length - 1].end, category: "db_migration" }] : [];
|
||||
|
||||
// Guarded categories: secret_assignment, stack_trace, legal.
|
||||
const guarded = regexHits.filter(
|
||||
(h) => h.category === "secret_assignment" || h.category === "stack_trace" || h.category === "legal"
|
||||
);
|
||||
// private_key is the only regex-hit self-evident category; k8s_secret and
|
||||
// db_migration self-promote via their own structural paths below.
|
||||
const selfEvident = regexHits.filter((h) => h.category === "private_key");
|
||||
|
||||
// Count corroborating signals (self-evident + promoted-ddl + k8s + guarded).
|
||||
const signalCount = selfEvident.length + (ddlPromoted.length ? 1 : 0) + k8sHits.length + guarded.length;
|
||||
const shortSection = !vcs && text.length < SHORT_SECTION;
|
||||
const guardedPromoted = signalCount >= 2 || shortSection ? guarded : [];
|
||||
|
||||
const promoted: RiskSpan[] = [
|
||||
...selfEvident.map((h) => ({ start: h.start, end: h.end, category: h.category })),
|
||||
...k8sHits.map((h) => ({ start: h.start, end: h.end, category: h.category })),
|
||||
...ddlPromoted,
|
||||
...guardedPromoted.map((h) => ({ start: h.start, end: h.end, category: h.category })),
|
||||
];
|
||||
|
||||
return mergeSpans(promoted);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
115
open-sse/services/compression/riskGate/riskGateStep.ts
Normal file
115
open-sse/services/compression/riskGate/riskGateStep.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { preserveSpans, restorePreservedBlocks, type PreservedBlock } from "../preservation.ts";
|
||||
import { detectRiskSpans, type RiskGateConfig } from "./riskGate.ts";
|
||||
import type { RiskCategory } from "./riskPatterns.ts";
|
||||
|
||||
export interface RiskGateStats {
|
||||
spansProtected: number;
|
||||
categories: Partial<Record<RiskCategory, number>>;
|
||||
}
|
||||
|
||||
export interface RiskMaskResult {
|
||||
maskedBody: Record<string, unknown>;
|
||||
blocks: PreservedBlock[];
|
||||
stats: RiskGateStats;
|
||||
}
|
||||
|
||||
interface TextPart {
|
||||
type?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Mask one content string; returns masked text + blocks + per-category counts. */
|
||||
function maskString(
|
||||
text: string,
|
||||
cfg: RiskGateConfig,
|
||||
tally: Partial<Record<RiskCategory, number>>
|
||||
): { masked: string; blocks: PreservedBlock[] } {
|
||||
const spans = detectRiskSpans(text, cfg);
|
||||
if (!spans.length) return { masked: text, blocks: [] };
|
||||
for (const s of spans) tally[s.category] = (tally[s.category] ?? 0) + 1;
|
||||
const { text: masked, blocks } = preserveSpans(
|
||||
text,
|
||||
spans.map((s) => ({ start: s.start, end: s.end, kind: `risk_${s.category}` }))
|
||||
);
|
||||
return { masked, blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask risky spans in every message content (string or `{type:"text"}` parts).
|
||||
* Pure: clones touched messages, leaves the original body unmutated. Fail-open.
|
||||
*/
|
||||
export function applyRiskMask(body: Record<string, unknown>, cfg: RiskGateConfig): RiskMaskResult {
|
||||
const tally: Partial<Record<RiskCategory, number>> = {};
|
||||
const allBlocks: PreservedBlock[] = [];
|
||||
const messages = body.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return { maskedBody: body, blocks: [], stats: { spansProtected: 0, categories: {} } };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const maskedMessages = messages.map((msg) => {
|
||||
const m = msg as { role?: unknown; content?: unknown };
|
||||
if (typeof m.content === "string") {
|
||||
const { masked, blocks } = maskString(m.content, cfg, tally);
|
||||
if (!blocks.length) return msg;
|
||||
changed = true;
|
||||
allBlocks.push(...blocks);
|
||||
return { ...m, content: masked };
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
let partChanged = false;
|
||||
const parts = (m.content as TextPart[]).map((p) => {
|
||||
if (p && p.type === "text" && typeof p.text === "string") {
|
||||
const { masked, blocks } = maskString(p.text, cfg, tally);
|
||||
if (!blocks.length) return p;
|
||||
partChanged = true;
|
||||
allBlocks.push(...blocks);
|
||||
return { ...p, text: masked };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (!partChanged) return msg;
|
||||
changed = true;
|
||||
return { ...m, content: parts };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
const maskedBody = changed ? { ...body, messages: maskedMessages } : body;
|
||||
return {
|
||||
maskedBody,
|
||||
blocks: allBlocks,
|
||||
stats: { spansProtected: allBlocks.length, categories: tally },
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore every masked span in the (possibly compressed) body. Fail-open. */
|
||||
export function restoreRiskBlocks(
|
||||
body: Record<string, unknown>,
|
||||
blocks: PreservedBlock[]
|
||||
): Record<string, unknown> {
|
||||
if (!blocks.length) return body;
|
||||
const messages = body.messages;
|
||||
if (!Array.isArray(messages)) return body;
|
||||
const restoreParts = (content: unknown): unknown => {
|
||||
if (typeof content === "string") return restorePreservedBlocks(content, blocks);
|
||||
if (Array.isArray(content)) {
|
||||
return (content as TextPart[]).map((p) =>
|
||||
p && p.type === "text" && typeof p.text === "string"
|
||||
? { ...p, text: restorePreservedBlocks(p.text, blocks) }
|
||||
: p
|
||||
);
|
||||
}
|
||||
return content;
|
||||
};
|
||||
return {
|
||||
...body,
|
||||
messages: messages.map((msg) => {
|
||||
const m = msg as { content?: unknown };
|
||||
if (typeof m.content === "string" || Array.isArray(m.content)) {
|
||||
return { ...m, content: restoreParts(m.content) };
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
};
|
||||
}
|
||||
61
open-sse/services/compression/riskGate/riskPatterns.ts
Normal file
61
open-sse/services/compression/riskGate/riskPatterns.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Risk-gate pattern catalog. Every variable-length pattern uses bounded
|
||||
* quantifiers (`{0,N}`) to prevent catastrophic backtracking (ReDoS) on
|
||||
* untrusted input. Patterns are ours (not agent-supplied), so safe-regex is not
|
||||
* required — boundedness is verified by an adversarial-input test.
|
||||
*/
|
||||
export type RiskCategory =
|
||||
| "stack_trace"
|
||||
| "private_key"
|
||||
| "secret_assignment"
|
||||
| "k8s_secret"
|
||||
| "db_migration"
|
||||
| "legal";
|
||||
|
||||
export const MAX_PEM_LEN = 4096;
|
||||
|
||||
export interface RiskPattern {
|
||||
category: RiskCategory;
|
||||
regex: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories whose single match is strong enough evidence on its own (no
|
||||
* corroborating second signal / short-section required).
|
||||
* `k8s_secret` and `db_migration` are promoted structurally in riskGate.ts.
|
||||
*/
|
||||
export const SELF_EVIDENT: ReadonlySet<RiskCategory> = new Set<RiskCategory>([
|
||||
"private_key",
|
||||
"k8s_secret",
|
||||
"db_migration",
|
||||
]);
|
||||
|
||||
export const RISK_PATTERNS: RiskPattern[] = [
|
||||
{
|
||||
category: "private_key",
|
||||
regex: new RegExp(
|
||||
`-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\\s\\S]{1,${MAX_PEM_LEN}}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----`,
|
||||
"g"
|
||||
),
|
||||
},
|
||||
{
|
||||
category: "secret_assignment",
|
||||
regex:
|
||||
/\b(?:api[_-]?key|secret|token|password|passwd|bearer|authorization|client[_-]?secret)\b[ \t]{0,20}[:=][ \t]{0,20}["']?[A-Za-z0-9._\-+/]{8,200}/gi,
|
||||
},
|
||||
{
|
||||
category: "stack_trace",
|
||||
regex:
|
||||
/^\s{0,8}(?:at\s+\S.{0,300}|File ".{1,300}", line \d{1,9}|Traceback \(most recent call last\):|[A-Za-z_.]{1,80}(?:Error|Exception):.{0,300})$/gm,
|
||||
},
|
||||
{
|
||||
// Single DDL hit; the ">=2 DDL" promotion rule is enforced in riskGate.ts.
|
||||
category: "db_migration",
|
||||
regex: /\b(?:CREATE|ALTER|DROP)\s+(?:TABLE|INDEX|SCHEMA|DATABASE|COLUMN)\b/gi,
|
||||
},
|
||||
{
|
||||
category: "legal",
|
||||
regex:
|
||||
/\bWITHOUT WARRANTY\b|\bPermission is hereby granted\b|^SPDX-License-Identifier:.{0,200}$|\bCopyright \(c\)\b|\bAll rights reserved\b/gim,
|
||||
},
|
||||
];
|
||||
44
open-sse/services/compression/riskGate/strategyWrap.ts
Normal file
44
open-sse/services/compression/riskGate/strategyWrap.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CompressionResult } from "../types.ts";
|
||||
import type { CompressionConfig } from "../types.ts";
|
||||
import { applyRiskMask, restoreRiskBlocks } from "./riskGateStep.ts";
|
||||
import type { RiskGateConfig } from "./riskGate.ts";
|
||||
|
||||
/** Resolve the effective risk-gate config (explicit option wins over config); enabled-gated. */
|
||||
export function resolveRiskGate(options?: {
|
||||
riskGate?: RiskGateConfig;
|
||||
config?: CompressionConfig;
|
||||
}): RiskGateConfig | undefined {
|
||||
const rg = options?.riskGate ?? options?.config?.riskGate;
|
||||
return rg?.enabled ? rg : undefined;
|
||||
}
|
||||
|
||||
function attach(
|
||||
result: CompressionResult,
|
||||
mask: ReturnType<typeof applyRiskMask>
|
||||
): CompressionResult {
|
||||
if (mask.blocks.length) result.body = restoreRiskBlocks(result.body, mask.blocks);
|
||||
if (result.stats) result.stats.riskGate = mask.stats;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Outer mask→run→restore wrapper for a sync compression entry point. Byte-identical when gate absent. */
|
||||
export function withRiskGate(
|
||||
body: Record<string, unknown>,
|
||||
riskGate: RiskGateConfig | undefined,
|
||||
run: (b: Record<string, unknown>) => CompressionResult
|
||||
): CompressionResult {
|
||||
if (!riskGate) return run(body);
|
||||
const mask = applyRiskMask(body, riskGate);
|
||||
return attach(run(mask.maskedBody), mask);
|
||||
}
|
||||
|
||||
/** Async variant of withRiskGate. */
|
||||
export async function withRiskGateAsync(
|
||||
body: Record<string, unknown>,
|
||||
riskGate: RiskGateConfig | undefined,
|
||||
run: (b: Record<string, unknown>) => Promise<CompressionResult>
|
||||
): Promise<CompressionResult> {
|
||||
if (!riskGate) return run(body);
|
||||
const mask = applyRiskMask(body, riskGate);
|
||||
return attach(await run(mask.maskedBody), mask);
|
||||
}
|
||||
@@ -33,6 +33,12 @@ import {
|
||||
} from "./planResolution.ts";
|
||||
import { resolveAdaptivePlan } from "./adaptiveCompression/resolveAdaptivePlan.ts";
|
||||
import type { AdaptiveTelemetry } from "./adaptiveCompression/types.ts";
|
||||
import type { RiskGateConfig } from "./riskGate/riskGate.ts";
|
||||
import {
|
||||
resolveRiskGate,
|
||||
withRiskGate,
|
||||
withRiskGateAsync,
|
||||
} from "./riskGate/strategyWrap.ts";
|
||||
|
||||
// Re-export so existing importers (resolver test + chatCore dynamic import) keep resolving.
|
||||
export { planFromHeader, formatCompressionMeta, buildNamedComboLookup };
|
||||
@@ -260,6 +266,23 @@ export function applyCompression(
|
||||
* skipped instead of silently dropping the target. Flows through to applyStackedCompression.
|
||||
*/
|
||||
bailout?: BailoutConfig;
|
||||
/** Risk-gate mask/restore wrapper (opt-in, default off). Read via resolveRiskGate. */
|
||||
riskGate?: RiskGateConfig;
|
||||
}
|
||||
): CompressionResult {
|
||||
return withRiskGate(body, resolveRiskGate(options), (b) => runCompression(b, mode, options));
|
||||
}
|
||||
|
||||
function runCompression(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
config?: CompressionConfig;
|
||||
principalId?: string;
|
||||
bailout?: BailoutConfig;
|
||||
riskGate?: RiskGateConfig;
|
||||
}
|
||||
): CompressionResult {
|
||||
if (mode === "off") {
|
||||
@@ -556,6 +579,8 @@ interface StackOptions {
|
||||
bailout?: BailoutConfig;
|
||||
/** Opt-in per-step fidelity gate (default disabled). */
|
||||
fidelityGate?: FidelityGateConfig;
|
||||
/** Risk-gate mask/restore wrapper (opt-in, default off). Read via resolveRiskGate. */
|
||||
riskGate?: RiskGateConfig;
|
||||
/** Authenticated principal id — threaded through to CCR engine for store scoping. */
|
||||
principalId?: string;
|
||||
/** F3.3: called once per engine as it completes (live per-engine streaming). */
|
||||
@@ -713,6 +738,16 @@ export function applyStackedCompression(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): CompressionResult {
|
||||
return withRiskGate(body, resolveRiskGate(options), (b) =>
|
||||
runStackedCompression(b, pipeline, options)
|
||||
);
|
||||
}
|
||||
|
||||
function runStackedCompression(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): CompressionResult {
|
||||
const steps = resolveStackSteps(pipeline);
|
||||
registerBuiltinCompressionEngines();
|
||||
@@ -786,6 +821,16 @@ export async function applyStackedCompressionAsync(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): Promise<CompressionResult> {
|
||||
return withRiskGateAsync(body, resolveRiskGate(options), (b) =>
|
||||
runStackedCompressionAsync(b, pipeline, options)
|
||||
);
|
||||
}
|
||||
|
||||
async function runStackedCompressionAsync(
|
||||
body: Record<string, unknown>,
|
||||
pipeline?: Array<CompressionPipelineStep | string>,
|
||||
options?: StackOptions
|
||||
): Promise<CompressionResult> {
|
||||
const steps = resolveStackSteps(pipeline);
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import { ENGINE_IDS } from "./engineCatalog.ts";
|
||||
import type { ContextBudgetConfig } from "./adaptiveCompression/types.ts";
|
||||
import type { FidelityGateConfig } from "./fidelityGate.ts";
|
||||
import type { RiskGateConfig } from "./riskGate/riskGate.ts";
|
||||
import type { RiskGateStats } from "./riskGate/riskGateStep.ts";
|
||||
|
||||
// Re-export so consumers that already import from this module (e.g. src/lib/db/compression.ts)
|
||||
// can get ENGINE_IDS without a second bare `@omniroute/open-sse/...engineCatalog.ts` specifier.
|
||||
@@ -144,6 +146,8 @@ export interface CompressionConfig {
|
||||
stackedPipeline?: CompressionPipelineStep[];
|
||||
/** Opt-in per-step fidelity gate (default disabled). */
|
||||
fidelityGate?: FidelityGateConfig;
|
||||
/** Opt-in risk-gate pre-pass: shields sensitive spans from compression (default disabled). */
|
||||
riskGate?: RiskGateConfig;
|
||||
cavemanConfig?: CavemanConfig;
|
||||
cavemanOutputMode?: CavemanOutputModeConfig;
|
||||
/** Phase 4A: selected output styles (supersedes cavemanOutputMode via a back-compat shim). */
|
||||
@@ -200,6 +204,7 @@ export interface CompressionStats {
|
||||
validationWarnings?: string[];
|
||||
validationErrors?: string[];
|
||||
fallbackApplied?: boolean;
|
||||
riskGate?: RiskGateStats;
|
||||
/**
|
||||
* Phase 4 (B): which `ultra` tier actually ran for this request.
|
||||
* "slm" — Tier-B ran and produced the output.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WaterfallInspector } from "./WaterfallInspector";
|
||||
import { DiffPane } from "./DiffPane";
|
||||
import { EncoderComparisonTable } from "./EncoderComparisonTable";
|
||||
import { PlaygroundInput, LANE_ENGINES } from "./PlaygroundInput";
|
||||
import { RiskGateBadge } from "./RiskGateBadge";
|
||||
export interface PlayViewProps {
|
||||
text: string;
|
||||
onText: (t: string) => void;
|
||||
@@ -45,6 +46,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
|
||||
const [fuzzyDedup, setFuzzyDedup] = useState(false);
|
||||
const [selectedLane, setSelectedLane] = useState<string | null>(null);
|
||||
const [fidelityGate, setFidelityGate] = useState(false);
|
||||
const [riskGate, setRiskGate] = useState(false);
|
||||
const { batch, loading, run } = usePreviewCompression();
|
||||
const messages = [{ role: "user", content: text }];
|
||||
const toggle = (e: string) =>
|
||||
@@ -56,6 +58,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
|
||||
activeEngines: orderByStack(active, laneEngines),
|
||||
fidelityGate,
|
||||
fuzzyDedup,
|
||||
riskGate,
|
||||
});
|
||||
const activeDiff = resolveActiveDiff(batch, selectedLane);
|
||||
return (
|
||||
@@ -72,6 +75,8 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
|
||||
onToggleFidelity={() => setFidelityGate((v) => !v)}
|
||||
fuzzyDedup={fuzzyDedup}
|
||||
onToggleFuzzy={() => setFuzzyDedup((v) => !v)}
|
||||
riskGate={riskGate}
|
||||
onToggleRisk={() => setRiskGate((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 overflow-auto">
|
||||
@@ -81,6 +86,7 @@ export function PlayView({ text, onText, laneEngines = LANE_ENGINES }: PlayViewP
|
||||
Fluxo combinado — {active.join(" → ")}
|
||||
</header>
|
||||
<WaterfallInspector run={batch.combined} />
|
||||
<RiskGateBadge stats={batch?.riskGate ?? null} />
|
||||
</section>
|
||||
)}
|
||||
<section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
export const LANE_ENGINES = ["session-dedup", "ccr", "lite", "rtk", "ionizer", "headroom", "caveman", "aggressive", "ultra"] as const;
|
||||
export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; }
|
||||
export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy }: PlaygroundInputProps) {
|
||||
export interface PlaygroundInputProps { text: string; onText: (t: string) => void; active: string[]; onToggleActive: (engine: string) => void; onRun: () => void; loading: boolean; fidelityGate: boolean; onToggleFidelity: () => void; fuzzyDedup: boolean; onToggleFuzzy: () => void; riskGate: boolean; onToggleRisk: () => void; }
|
||||
export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, loading, fidelityGate, onToggleFidelity, fuzzyDedup, onToggleFuzzy, riskGate, onToggleRisk }: PlaygroundInputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea data-testid="play-input" className="min-h-[160px] w-full rounded border p-2 font-mono text-xs" value={text} onChange={(e) => onText(e.target.value)} placeholder="Cole prompt / tool-output / contexto..." />
|
||||
@@ -18,6 +18,10 @@ export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, l
|
||||
<input type="checkbox" data-testid="fuzzy-toggle" checked={fuzzyDedup} onChange={onToggleFuzzy} />
|
||||
Fuzzy dedup (near-duplicate → CCR)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="risk-toggle" checked={riskGate} onChange={onToggleRisk} />
|
||||
Proteger conteúdo sensível (risk-gate)
|
||||
</label>
|
||||
<button data-testid="play-run" className="rounded bg-blue-500/30 py-2 font-semibold" onClick={onRun} disabled={loading}>{loading ? "Rodando..." : "▶ Run"}</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import type { RiskGateStats } from "./compressionFlowModel.ts";
|
||||
|
||||
export function RiskGateBadge({ stats }: { stats: RiskGateStats | null | undefined }): React.ReactElement | null {
|
||||
if (!stats || stats.spansProtected <= 0) return null;
|
||||
const cats = Object.entries(stats.categories)
|
||||
.filter(([, n]) => (n ?? 0) > 0)
|
||||
.map(([k, n]) => `${k} ×${n}`)
|
||||
.join(", ");
|
||||
return (
|
||||
<div data-testid="risk-gate-badge" className="text-sm text-amber-700">
|
||||
🛡️ {stats.spansProtected} risky span(s) protected{cats ? ` (${cats})` : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,13 @@ export interface EncoderComparison {
|
||||
winner: "gcf" | "toon" | "json";
|
||||
}
|
||||
|
||||
// ── Risk-gate (protected-span stats) ──────────────────────────────────────
|
||||
|
||||
export interface RiskGateStats {
|
||||
spansProtected: number;
|
||||
categories: Partial<Record<string, number>>;
|
||||
}
|
||||
|
||||
// ── Preview API response ──────────────────────────────────────────────────
|
||||
|
||||
export interface PreviewResponse {
|
||||
@@ -60,6 +67,7 @@ export interface PreviewResponse {
|
||||
preservedBlocks: Array<{ kind: string; preview: string }>;
|
||||
ruleRemovals: string[];
|
||||
encoderComparison?: EncoderComparison | null;
|
||||
riskGate?: RiskGateStats | null;
|
||||
}
|
||||
|
||||
// ── Run Model ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,6 +40,10 @@ export const PreviewRequestSchema = z.object({
|
||||
// / checkDiffHunks on FidelityGateConfig) use their conservative defaults until the studio gets
|
||||
// a config panel for them.
|
||||
fidelityGate: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Playground risk-gate toggle → masks high-risk spans (secrets/keys) before compression and
|
||||
// restores them verbatim after, so they pass through byte-identical. Reported via
|
||||
// result.stats.riskGate (spansProtected + per-category counts).
|
||||
riskGate: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Playground fuzzy near-duplicate toggle → injects `{ fuzzy: { enabled: true } }` into the
|
||||
// session-dedup step config (see buildStep).
|
||||
fuzzyDedup: z.object({ enabled: z.boolean() }).optional(),
|
||||
@@ -49,6 +53,10 @@ function countTokens(text: string): number {
|
||||
return countTextTokens(text);
|
||||
}
|
||||
|
||||
function riskGateStatsOf(result: { stats?: { riskGate?: unknown } }): unknown {
|
||||
return result.stats?.riskGate ?? null;
|
||||
}
|
||||
|
||||
function messagesToText(messages: Array<{ role: string; content: unknown }>): string {
|
||||
return messages
|
||||
.map((m) => {
|
||||
@@ -87,13 +95,18 @@ async function dispatchCompression(
|
||||
config?: unknown;
|
||||
fidelityGate?: { enabled: boolean };
|
||||
fuzzyDedup?: { enabled: boolean };
|
||||
riskGate?: { enabled: boolean };
|
||||
}
|
||||
) {
|
||||
// resolveRiskGate reads `options.riskGate ?? options.config.riskGate`. applyCompressionAsync
|
||||
// does not surface a top-level `riskGate` option, so thread it through the synthesized config
|
||||
// (CompressionConfig.riskGate) — uniform across all three branches and type-safe.
|
||||
if (opts.engineId) {
|
||||
return applyCompressionAsync(requestBody, "stacked", {
|
||||
config: {
|
||||
stackedPipeline: [buildStep(opts.engineId, opts.fuzzyDedup)],
|
||||
...(opts.fidelityGate ? { fidelityGate: opts.fidelityGate } : {}),
|
||||
...(opts.riskGate ? { riskGate: opts.riskGate } : {}),
|
||||
} as CompressionConfig,
|
||||
});
|
||||
}
|
||||
@@ -102,6 +115,7 @@ async function dispatchCompression(
|
||||
config: {
|
||||
stackedPipeline: opts.pipeline.map((engine) => buildStep(engine, opts.fuzzyDedup)),
|
||||
...(opts.fidelityGate ? { fidelityGate: opts.fidelityGate } : {}),
|
||||
...(opts.riskGate ? { riskGate: opts.riskGate } : {}),
|
||||
} as CompressionConfig,
|
||||
});
|
||||
}
|
||||
@@ -109,6 +123,7 @@ async function dispatchCompression(
|
||||
config: {
|
||||
...(opts.config as CompressionConfig | undefined),
|
||||
...(opts.fidelityGate ? { fidelityGate: opts.fidelityGate } : {}),
|
||||
...(opts.riskGate ? { riskGate: opts.riskGate } : {}),
|
||||
} as CompressionConfig | undefined,
|
||||
});
|
||||
}
|
||||
@@ -132,7 +147,8 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const { messages, mode, engineId, pipeline, config, fidelityGate, fuzzyDedup } = parsed.data;
|
||||
const { messages, mode, engineId, pipeline, config, fidelityGate, fuzzyDedup, riskGate } =
|
||||
parsed.data;
|
||||
const effectiveMode: CompressionMode =
|
||||
engineId || pipeline ? "stacked" : (mode as CompressionMode);
|
||||
const originalText = messagesToText(messages);
|
||||
@@ -148,6 +164,7 @@ export async function POST(req: Request) {
|
||||
config,
|
||||
fidelityGate,
|
||||
fuzzyDedup,
|
||||
riskGate,
|
||||
});
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
@@ -177,6 +194,7 @@ export async function POST(req: Request) {
|
||||
savingsPct,
|
||||
techniquesUsed,
|
||||
engineBreakdown,
|
||||
riskGate: riskGateStatsOf(result),
|
||||
durationMs,
|
||||
mode: effectiveMode,
|
||||
intensity: null,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useCallback, useState } from "react";
|
||||
import { previewToRunModel, type CompressionRunModel, type PreviewResponse } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
export interface PreviewMessage { role: string; content: unknown; }
|
||||
export interface Lane { engine: string; run: CompressionRunModel | null; error: string | null; }
|
||||
export interface PreviewBatch { lanes: Lane[]; combined: CompressionRunModel | null; diff: PreviewResponse["diff"] | null; }
|
||||
export interface RunPreviewArgs { messages: PreviewMessage[]; laneEngines: string[]; activeEngines: string[]; language?: string; fidelityGate?: boolean; fuzzyDedup?: boolean; }
|
||||
export interface PreviewBatch { lanes: Lane[]; combined: CompressionRunModel | null; diff: PreviewResponse["diff"] | null; riskGate: PreviewResponse["riskGate"] | null; }
|
||||
export interface RunPreviewArgs { messages: PreviewMessage[]; laneEngines: string[]; activeEngines: string[]; language?: string; fidelityGate?: boolean; fuzzyDedup?: boolean; riskGate?: boolean; }
|
||||
async function postPreview(payload: Record<string, unknown>): Promise<PreviewResponse> {
|
||||
const res = await fetch("/api/compression/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
const data = await res.json();
|
||||
@@ -12,10 +12,11 @@ async function postPreview(payload: Record<string, unknown>): Promise<PreviewRes
|
||||
return data as PreviewResponse;
|
||||
}
|
||||
export async function runPreviewBatch(args: RunPreviewArgs): Promise<PreviewBatch> {
|
||||
const { messages, laneEngines, activeEngines, fidelityGate, fuzzyDedup } = args;
|
||||
const { messages, laneEngines, activeEngines, fidelityGate, fuzzyDedup, riskGate } = args;
|
||||
const extra = {
|
||||
...(fidelityGate ? { fidelityGate: { enabled: true } } : {}),
|
||||
...(fuzzyDedup ? { fuzzyDedup: { enabled: true } } : {}),
|
||||
...(riskGate ? { riskGate: { enabled: true } } : {}),
|
||||
};
|
||||
const lanes: Lane[] = await Promise.all(
|
||||
laneEngines.map(async (engine): Promise<Lane> => {
|
||||
@@ -25,11 +26,12 @@ export async function runPreviewBatch(args: RunPreviewArgs): Promise<PreviewBatc
|
||||
);
|
||||
let combined: CompressionRunModel | null = null;
|
||||
let diff: PreviewResponse["diff"] | null = null;
|
||||
let riskGateStats: PreviewResponse["riskGate"] | null = null;
|
||||
if (activeEngines.length > 0) {
|
||||
try { const res = await postPreview({ messages, pipeline: activeEngines, ...extra }); combined = previewToRunModel(res, activeEngines.join(" → ")); diff = res.diff; }
|
||||
try { const res = await postPreview({ messages, pipeline: activeEngines, ...extra }); combined = previewToRunModel(res, activeEngines.join(" → ")); diff = res.diff; riskGateStats = res.riskGate ?? null; }
|
||||
catch { combined = null; }
|
||||
}
|
||||
return { lanes, combined, diff };
|
||||
return { lanes, combined, diff, riskGate: riskGateStats };
|
||||
}
|
||||
export function usePreviewCompression() {
|
||||
const [batch, setBatch] = useState<PreviewBatch | null>(null);
|
||||
|
||||
105
tests/unit/compression/riskGateDetect.test.ts
Normal file
105
tests/unit/compression/riskGateDetect.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* TDD for the risk-gate pattern catalog (#5 compression roadmap).
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/riskGateDetect.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
RISK_PATTERNS,
|
||||
SELF_EVIDENT,
|
||||
MAX_PEM_LEN,
|
||||
} from "../../../open-sse/services/compression/riskGate/riskPatterns.ts";
|
||||
import { detectRiskSpans } from "../../../open-sse/services/compression/riskGate/riskGate.ts";
|
||||
|
||||
const ALL = { enabled: true } as const;
|
||||
|
||||
describe("riskPatterns catalog", () => {
|
||||
it("exposes one entry per category with a global regex", () => {
|
||||
const categories = RISK_PATTERNS.map((p) => p.category);
|
||||
for (const c of ["private_key", "secret_assignment", "stack_trace", "db_migration", "legal"]) {
|
||||
assert.ok(categories.includes(c as never), `missing pattern for ${c}`);
|
||||
}
|
||||
for (const p of RISK_PATTERNS) assert.ok(p.regex.flags.includes("g"), `${p.category} regex must be global`);
|
||||
});
|
||||
|
||||
it("marks private_key as self-evident and secret_assignment as guarded", () => {
|
||||
assert.equal(SELF_EVIDENT.has("private_key"), true);
|
||||
assert.equal(SELF_EVIDENT.has("secret_assignment"), false);
|
||||
});
|
||||
|
||||
it("private_key regex is bounded — adversarial input returns promptly", () => {
|
||||
const evil = "-----BEGIN PRIVATE KEY-----\n" + "A".repeat(20000); // never closed
|
||||
const start = Date.now();
|
||||
RISK_PATTERNS.find((p) => p.category === "private_key")!.regex.lastIndex = 0;
|
||||
const m = RISK_PATTERNS.find((p) => p.category === "private_key")!.regex.exec(evil);
|
||||
assert.equal(m, null, "unterminated key must not match");
|
||||
assert.ok(Date.now() - start < 200, "bounded regex must not hang");
|
||||
assert.ok(MAX_PEM_LEN <= 4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRiskSpans — guards", () => {
|
||||
it("promotes a self-evident PEM block on a single hit, even in a long message", () => {
|
||||
const pem =
|
||||
"-----BEGIN PRIVATE KEY-----\nMIIBVQ...short...body\n-----END PRIVATE KEY-----";
|
||||
const text = "prose ".repeat(60) + pem + " trailing ".repeat(60);
|
||||
const spans = detectRiskSpans(text, ALL);
|
||||
assert.equal(spans.length, 1);
|
||||
assert.equal(spans[0].category, "private_key");
|
||||
assert.equal(text.slice(spans[0].start, spans[0].end), pem);
|
||||
});
|
||||
|
||||
it("does NOT promote a lone guarded hit (secret_assignment) in a long message", () => {
|
||||
const text = "lorem ".repeat(100) + 'api_key="ABCDEFGH1234567890"' + " ipsum ".repeat(100);
|
||||
assert.equal(detectRiskSpans(text, ALL).length, 0);
|
||||
});
|
||||
|
||||
it("promotes a lone guarded hit inside a short (<200 char) section", () => {
|
||||
const text = 'api_key="ABCDEFGH1234567890"';
|
||||
const spans = detectRiskSpans(text, ALL);
|
||||
assert.equal(spans.length, 1);
|
||||
assert.equal(spans[0].category, "secret_assignment");
|
||||
});
|
||||
|
||||
it("promotes guarded hits when >=2 signals corroborate", () => {
|
||||
const text =
|
||||
"x".repeat(400) +
|
||||
'\npassword: "hunter2hunter2"\n' +
|
||||
" at foo (file.js:1:1)\n" +
|
||||
"y".repeat(400);
|
||||
const spans = detectRiskSpans(text, ALL);
|
||||
assert.ok(spans.length >= 2, "two corroborating signals promote both");
|
||||
});
|
||||
|
||||
it("promotes db_migration only with >=2 DDL statements", () => {
|
||||
const one = "x".repeat(400) + "\nALTER TABLE users ADD COLUMN x int;\n" + "y".repeat(400);
|
||||
assert.equal(detectRiskSpans(one, ALL).length, 0, "single DDL in prose is not flagged");
|
||||
const two =
|
||||
"x".repeat(400) +
|
||||
"\nCREATE TABLE a (id int);\nALTER TABLE a ADD COLUMN b int;\n" +
|
||||
"y".repeat(400);
|
||||
const spans = detectRiskSpans(two, ALL);
|
||||
assert.equal(spans.length, 1);
|
||||
assert.equal(spans[0].category, "db_migration");
|
||||
});
|
||||
|
||||
it("commit-log guard drops a DDL that only appears inside a diff hunk", () => {
|
||||
const text =
|
||||
"diff --git a/m.sql b/m.sql\n@@ -1,2 +1,3 @@\n+CREATE TABLE a (id int);\n+ALTER TABLE a ADD COLUMN b int;\n";
|
||||
assert.equal(detectRiskSpans(text, ALL).length, 0);
|
||||
});
|
||||
|
||||
it("detects a k8s Secret block structurally", () => {
|
||||
const text =
|
||||
"apiVersion: v1\nkind: Secret\nmetadata:\n name: s\ndata:\n token: aGVsbG8=\n";
|
||||
const spans = detectRiskSpans(text, ALL);
|
||||
assert.equal(spans.length, 1);
|
||||
assert.equal(spans[0].category, "k8s_secret");
|
||||
});
|
||||
|
||||
it("honors the categories allow-list", () => {
|
||||
const text = 'api_key="ABCDEFGH1234567890"';
|
||||
assert.equal(detectRiskSpans(text, { enabled: true, categories: ["legal"] }).length, 0);
|
||||
});
|
||||
});
|
||||
78
tests/unit/compression/riskGateIntegration.test.ts
Normal file
78
tests/unit/compression/riskGateIntegration.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* TDD for risk-gate end-to-end: shields a secret through a REAL engine,
|
||||
* and is byte-identical to baseline when disabled.
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/riskGateIntegration.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Isolate the preview-route call below from the operator's real ~/.omniroute DB:
|
||||
// a fresh empty DATA_DIR with no INITIAL_PASSWORD means isAuthRequired() is false,
|
||||
// so the management-auth gate lets the request through (matches previewRouteFidelity).
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "preview-riskgate-"));
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa";
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
|
||||
import { applyStackedCompression } from "../../../open-sse/services/compression/strategySelector.ts";
|
||||
import { registerBuiltinCompressionEngines } from "../../../open-sse/services/compression/engines/index.ts";
|
||||
|
||||
registerBuiltinCompressionEngines();
|
||||
|
||||
const PEM = "-----BEGIN PRIVATE KEY-----\nMIIBVQ0123456789abcdefBODY\n-----END PRIVATE KEY-----";
|
||||
const longProse = ("The quick brown fox jumps over the lazy dog. ".repeat(20)).trim();
|
||||
|
||||
function body() {
|
||||
return { messages: [{ role: "user", content: `${longProse}\n${PEM}\n${longProse}` }] };
|
||||
}
|
||||
|
||||
describe("risk-gate integration", () => {
|
||||
it("keeps the PEM byte-identical while compressing surrounding prose (real caveman)", () => {
|
||||
const res = applyStackedCompression(body(), [{ engine: "caveman", intensity: "full" }], {
|
||||
riskGate: { enabled: true },
|
||||
});
|
||||
const out = (res.body.messages as Array<{ content: string }>)[0].content;
|
||||
assert.ok(out.includes(PEM), "secret survived verbatim");
|
||||
assert.ok(!out.includes("OMNI_CAVEMAN"), "no placeholder leaked into output");
|
||||
assert.equal(res.stats?.riskGate?.spansProtected, 1);
|
||||
assert.equal(res.stats?.riskGate?.categories.private_key, 1);
|
||||
});
|
||||
|
||||
it("is byte-identical to the no-gate baseline when disabled", () => {
|
||||
const withoutOpt = applyStackedCompression(body(), [{ engine: "caveman", intensity: "full" }]);
|
||||
const disabled = applyStackedCompression(body(), [{ engine: "caveman", intensity: "full" }], {
|
||||
riskGate: { enabled: false },
|
||||
});
|
||||
assert.equal(
|
||||
(disabled.body.messages as Array<{ content: string }>)[0].content,
|
||||
(withoutOpt.body.messages as Array<{ content: string }>)[0].content
|
||||
);
|
||||
assert.equal(disabled.stats?.riskGate, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview route — riskGate", () => {
|
||||
it("accepts riskGate and returns protected-span stats", async () => {
|
||||
// Dynamic import so the DATA_DIR env above is in effect before the route's DB-path
|
||||
// module resolves — guaranteeing the fresh temp DB (setupComplete=false), which means
|
||||
// the loopback request below needs no management auth. (Mirrors previewRouteFidelity.)
|
||||
const { POST: previewPOST } = await import(
|
||||
"../../../src/app/api/compression/preview/route.ts"
|
||||
);
|
||||
const req = new Request("http://localhost/api/compression/preview", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: `${PEM}` }],
|
||||
engineId: "caveman",
|
||||
riskGate: { enabled: true },
|
||||
}),
|
||||
});
|
||||
const res = await previewPOST(req as never);
|
||||
const json = (await res.json()) as { riskGate?: { spansProtected: number }; compressed: string };
|
||||
assert.equal(json.riskGate?.spansProtected, 1);
|
||||
assert.ok(json.compressed.includes(PEM), "secret preserved in preview output");
|
||||
});
|
||||
});
|
||||
61
tests/unit/compression/riskGateStep.test.ts
Normal file
61
tests/unit/compression/riskGateStep.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* TDD for risk-gate mask/restore round-trip.
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/riskGateStep.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyRiskMask, restoreRiskBlocks } from "../../../open-sse/services/compression/riskGate/riskGateStep.ts";
|
||||
import {
|
||||
DEFAULT_COMPRESSION_CONFIG,
|
||||
type CompressionConfig,
|
||||
} from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
const PEM = "-----BEGIN PRIVATE KEY-----\nMIIBVQbody\n-----END PRIVATE KEY-----";
|
||||
|
||||
describe("applyRiskMask / restoreRiskBlocks", () => {
|
||||
it("masks risky spans in message content and round-trips byte-identically", () => {
|
||||
const body = { messages: [{ role: "user", content: `here is a key:\n${PEM}\nthanks` }] };
|
||||
const { maskedBody, blocks, stats } = applyRiskMask(body, { enabled: true });
|
||||
const maskedText = (maskedBody.messages as Array<{ content: string }>)[0].content;
|
||||
assert.ok(!maskedText.includes("BEGIN PRIVATE KEY"), "secret removed from masked body");
|
||||
assert.ok(maskedText.includes("OMNI_CAVEMAN"), "uses SENTINEL placeholder family");
|
||||
assert.equal(stats.spansProtected, 1);
|
||||
assert.equal(stats.categories.private_key, 1);
|
||||
|
||||
const restored = restoreRiskBlocks(maskedBody, blocks);
|
||||
assert.equal((restored.messages as Array<{ content: string }>)[0].content, body.messages[0].content);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing risky is present", () => {
|
||||
const body = { messages: [{ role: "user", content: "just normal prose, nothing to see" }] };
|
||||
const { maskedBody, blocks, stats } = applyRiskMask(body, { enabled: true });
|
||||
assert.equal(blocks.length, 0);
|
||||
assert.equal(stats.spansProtected, 0);
|
||||
assert.deepEqual(maskedBody, body);
|
||||
});
|
||||
|
||||
it("masks text parts inside array (multimodal) content", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: [{ type: "text", text: `key:\n${PEM}` }, { type: "image_url", image_url: { url: "x" } }] }],
|
||||
};
|
||||
const { maskedBody, blocks } = applyRiskMask(body, { enabled: true });
|
||||
const part = (maskedBody.messages as Array<{ content: Array<{ type: string; text?: string }> }>)[0].content[0];
|
||||
assert.ok(!part.text!.includes("BEGIN PRIVATE KEY"));
|
||||
const restored = restoreRiskBlocks(maskedBody, blocks);
|
||||
assert.equal(
|
||||
(restored.messages as Array<{ content: Array<{ text?: string }> }>)[0].content[0].text,
|
||||
`key:\n${PEM}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("risk-gate config defaults", () => {
|
||||
it("ships disabled by default", () => {
|
||||
assert.equal(DEFAULT_COMPRESSION_CONFIG.riskGate?.enabled ?? false, false);
|
||||
});
|
||||
it("accepts a riskGate field on CompressionConfig", () => {
|
||||
const cfg: CompressionConfig = { ...DEFAULT_COMPRESSION_CONFIG, riskGate: { enabled: true } };
|
||||
assert.equal(cfg.riskGate?.enabled, true);
|
||||
});
|
||||
});
|
||||
42
tests/unit/ui/riskGateBadge.test.tsx
Normal file
42
tests/unit/ui/riskGateBadge.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { RiskGateBadge } from "../../../src/app/(dashboard)/dashboard/compression/studio/RiskGateBadge.tsx";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => root.render(ui));
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
while (roots.length) roots.pop()?.unmount();
|
||||
});
|
||||
while (containers.length) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("RiskGateBadge", () => {
|
||||
it("renders the count + categories when spans were protected", () => {
|
||||
const c = mount(<RiskGateBadge stats={{ spansProtected: 2, categories: { private_key: 1, secret_assignment: 1 } }} />);
|
||||
expect(c.textContent).toContain("2");
|
||||
expect(c.textContent).toContain("private_key");
|
||||
});
|
||||
it("renders nothing when no spans / no stats", () => {
|
||||
expect(mount(<RiskGateBadge stats={null} />).textContent).toBe("");
|
||||
expect(mount(<RiskGateBadge stats={{ spansProtected: 0, categories: {} }} />).textContent).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user