mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
fix(vscode): sanitize implicit editor context (#4124)
Integrated into release/v3.8.29 with review fixes (narrowed text-scan redaction + documented OMNIROUTE_VSCODE_SANITIZE_CONTEXT + regression tests). Co-authored-by: diegosouzapw
This commit is contained in:
@@ -278,6 +278,15 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# PII_RESPONSE_SANITIZATION=false
|
||||
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
|
||||
|
||||
# ── VS Code Tokenized-Route Context Sanitizer ──
|
||||
# Strips implicit active-editor context (editorContext/activeEditor/currentFile/
|
||||
# selection/openTabs…) from requests on the /v1/vscode/[token]/* routes before
|
||||
# forwarding upstream, and redacts the content of explicitly-attached sensitive
|
||||
# files (.env, private keys, kubeconfig, credentials/secrets). Explicit
|
||||
# attachments otherwise pass through. Secure-by-default: ON unless set to 0.
|
||||
# Used by: src/app/api/v1/vscode/contextSanitizer.ts
|
||||
# OMNIROUTE_VSCODE_SANITIZE_CONTEXT=1 # set to 0 to disable
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 6. TOOL & ROUTING POLICIES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -213,6 +213,12 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
|
||||
| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | Scan LLM responses for leaked PII before returning to client. |
|
||||
| `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = mask PII, `warn` = log only, `block` = drop entire response. |
|
||||
|
||||
### VS Code Tokenized-Route Context Sanitizer
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ----------------------------------- | ------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `OMNIROUTE_VSCODE_SANITIZE_CONTEXT` | `1` | `src/app/api/v1/vscode/contextSanitizer.ts` | Strips implicit active-editor context (`editorContext`, `activeEditor`, `currentFile`, `selection`, `openTabs`…) from `/v1/vscode/[token]/*` requests and redacts content of explicitly-attached sensitive files. Secure-by-default; set to `0` to disable. |
|
||||
|
||||
### Scenarios
|
||||
|
||||
| Scenario | Configuration |
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export { POST, OPTIONS } from "@/app/api/v1/api/chat/route";
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/api/chat/route";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return basePost(await withSanitizedPathTokenApiKey(request));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/responses/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sanitizeVscodeRequest } from "@/app/api/v1/vscode/contextSanitizer";
|
||||
|
||||
function inferTokenFromVscodePath(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
@@ -47,4 +49,8 @@ export function withPathTokenApiKey(request: Request, token?: string) {
|
||||
}
|
||||
|
||||
return new Request(request.url, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withSanitizedPathTokenApiKey(request: Request, token?: string) {
|
||||
return sanitizeVscodeRequest(withPathTokenApiKey(request, token));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
283
src/app/api/v1/vscode/contextSanitizer.ts
Normal file
283
src/app/api/v1/vscode/contextSanitizer.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const IMPLICIT_CONTEXT_KEYS = new Set([
|
||||
"activedocument",
|
||||
"activeeditor",
|
||||
"activetexteditor",
|
||||
"currenteditor",
|
||||
"currentfile",
|
||||
"currentselection",
|
||||
"editorcontext",
|
||||
"opentabs",
|
||||
"selectedtext",
|
||||
"selection",
|
||||
"visibleeditors",
|
||||
]);
|
||||
|
||||
const EXPLICIT_CONTEXT_KEYS = new Set([
|
||||
"attachment",
|
||||
"attachments",
|
||||
"document",
|
||||
"documents",
|
||||
"file",
|
||||
"files",
|
||||
"reference",
|
||||
"references",
|
||||
]);
|
||||
|
||||
const FILE_PATH_KEYS = ["filePath", "filepath", "path", "uri", "url"];
|
||||
const CONTENT_KEYS = ["content", "contents", "text", "value", "data"];
|
||||
const SENSITIVE_CONTEXT_REPLACEMENT = "[REDACTED SENSITIVE CONTEXT]";
|
||||
|
||||
// Path/extension patterns anchored to path separators or file extensions, so
|
||||
// they only match actual file references. Safe to apply to free-form text.
|
||||
const SENSITIVE_FILENAME_PATTERNS = [
|
||||
/(^|[\\/])\.env(\.|$)/i,
|
||||
/(^|[\\/])\.netrc$/i,
|
||||
/(^|[\\/])\.npmrc$/i,
|
||||
/(^|[\\/])\.pypirc$/i,
|
||||
/(^|[\\/])id_ed25519$/i,
|
||||
/(^|[\\/])id_rsa$/i,
|
||||
/(^|[\\/])local-docs[\\/]server-access\.md$/i,
|
||||
/(^|[\\/])server-access\.md$/i,
|
||||
/\.key$/i,
|
||||
/\.p12$/i,
|
||||
/\.pem$/i,
|
||||
/\.pfx$/i,
|
||||
];
|
||||
|
||||
// Bare-word patterns. They correctly flag a sensitive *path segment* (e.g.
|
||||
// `~/.aws/credentials`) at the object level, but the same words appear in
|
||||
// legitimate prose ("how do I store API credentials?"). They must therefore be
|
||||
// used ONLY for structured path detection, never to scrub free-form text.
|
||||
const SENSITIVE_KEYWORD_PATTERNS = [/credentials/i, /kubeconfig/i, /secrets?/i];
|
||||
|
||||
// Object-level path detection (file path + content pairs) uses the full set.
|
||||
const SENSITIVE_PATH_PATTERNS = [...SENSITIVE_FILENAME_PATTERNS, ...SENSITIVE_KEYWORD_PATTERNS];
|
||||
|
||||
// Free-form text scanning uses only the anchored filename patterns, so ordinary
|
||||
// chat content mentioning words like "credentials" or "secrets" is preserved.
|
||||
const SENSITIVE_TEXT_PATTERNS = SENSITIVE_FILENAME_PATTERNS;
|
||||
|
||||
const IMPLICIT_TEXT_BLOCK_PATTERN =
|
||||
/(^|\n)(active editor|current file|current selection|editor context|open tabs|selected text|visible editors):[\s\S]*?(?=\n\n(?:[A-Z][A-Za-z0-9 _-]{1,60}:|User request:|Request:)|$)/gi;
|
||||
|
||||
export type VscodeContextSanitizerAudit = {
|
||||
removedImplicitKeys: string[];
|
||||
redactedSensitivePaths: string[];
|
||||
};
|
||||
|
||||
export type VscodeContextSanitizerResult<T> = {
|
||||
body: T;
|
||||
changed: boolean;
|
||||
audit: VscodeContextSanitizerAudit;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizePath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith("file://")) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(trimmed).pathname);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getObjectPath(value: JsonObject): string | null {
|
||||
for (const key of FILE_PATH_KEYS) {
|
||||
const normalized = normalizePath(value[key]);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSensitivePath(path: string): boolean {
|
||||
return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
function collectExplicitPaths(
|
||||
value: unknown,
|
||||
parentKey = "",
|
||||
paths = new Set<string>()
|
||||
): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectExplicitPaths(item, parentKey, paths);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return paths;
|
||||
|
||||
const lowerParentKey = parentKey.toLowerCase();
|
||||
const objectPath = getObjectPath(value);
|
||||
if (objectPath && EXPLICIT_CONTEXT_KEYS.has(lowerParentKey)) {
|
||||
paths.add(objectPath);
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
collectExplicitPaths(child, key, paths);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function redactSensitiveObject(value: JsonObject, audit: VscodeContextSanitizerAudit): boolean {
|
||||
const objectPath = getObjectPath(value);
|
||||
if (!objectPath || !isSensitivePath(objectPath)) return false;
|
||||
|
||||
audit.redactedSensitivePaths.push(objectPath);
|
||||
let changed = false;
|
||||
|
||||
for (const key of CONTENT_KEYS) {
|
||||
if (key in value && value[key] !== SENSITIVE_CONTEXT_REPLACEMENT) {
|
||||
value[key] = SENSITIVE_CONTEXT_REPLACEMENT;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function sanitizeString(
|
||||
value: string,
|
||||
explicitPaths: Set<string>,
|
||||
audit: VscodeContextSanitizerAudit,
|
||||
parentKey = ""
|
||||
) {
|
||||
let changed = false;
|
||||
let sanitized = value.replace(IMPLICIT_TEXT_BLOCK_PATTERN, (block, leadingNewline = "") => {
|
||||
const referencesExplicitPath = [...explicitPaths].some((path) => path && block.includes(path));
|
||||
if (referencesExplicitPath) return block;
|
||||
|
||||
changed = true;
|
||||
audit.removedImplicitKeys.push("text:implicit-editor-context");
|
||||
return leadingNewline || "";
|
||||
});
|
||||
|
||||
if (!FILE_PATH_KEYS.includes(parentKey)) {
|
||||
for (const pattern of SENSITIVE_TEXT_PATTERNS) {
|
||||
if (pattern.test(sanitized)) {
|
||||
sanitized = sanitized.replace(pattern, SENSITIVE_CONTEXT_REPLACEMENT);
|
||||
changed = true;
|
||||
audit.redactedSensitivePaths.push("text:sensitive-path");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { value: sanitized, changed };
|
||||
}
|
||||
|
||||
function sanitizeValue(
|
||||
value: unknown,
|
||||
explicitPaths: Set<string>,
|
||||
audit: VscodeContextSanitizerAudit,
|
||||
parentKey = ""
|
||||
): { value: unknown; changed: boolean } {
|
||||
if (typeof value === "string") {
|
||||
return sanitizeString(value, explicitPaths, audit, parentKey);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const sanitizedItems = value.map((item) => {
|
||||
const result = sanitizeValue(item, explicitPaths, audit, parentKey);
|
||||
changed ||= result.changed;
|
||||
return result.value;
|
||||
});
|
||||
|
||||
return { value: changed ? sanitizedItems : value, changed };
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const sanitized: JsonObject = { ...value };
|
||||
|
||||
for (const key of Object.keys(sanitized)) {
|
||||
if (IMPLICIT_CONTEXT_KEYS.has(key.toLowerCase())) {
|
||||
delete sanitized[key];
|
||||
audit.removedImplicitKeys.push(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
changed = redactSensitiveObject(sanitized, audit) || changed;
|
||||
|
||||
for (const [key, child] of Object.entries(sanitized)) {
|
||||
const result = sanitizeValue(child, explicitPaths, audit, key);
|
||||
if (result.changed) {
|
||||
sanitized[key] = result.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return { value, changed: false };
|
||||
return { value: sanitized, changed: true };
|
||||
}
|
||||
|
||||
function shouldSanitizeVscodeContext(): boolean {
|
||||
return process.env.OMNIROUTE_VSCODE_SANITIZE_CONTEXT !== "0";
|
||||
}
|
||||
|
||||
export function sanitizeVscodeRequestBody<T>(body: T): VscodeContextSanitizerResult<T> {
|
||||
const audit: VscodeContextSanitizerAudit = {
|
||||
removedImplicitKeys: [],
|
||||
redactedSensitivePaths: [],
|
||||
};
|
||||
|
||||
if (!shouldSanitizeVscodeContext()) {
|
||||
return { body, changed: false, audit };
|
||||
}
|
||||
|
||||
const explicitPaths = collectExplicitPaths(body);
|
||||
const result = sanitizeValue(body, explicitPaths, audit);
|
||||
|
||||
return {
|
||||
body: result.value as T,
|
||||
changed: result.changed,
|
||||
audit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sanitizeVscodeRequest(request: Request): Promise<Request> {
|
||||
if (!["PATCH", "POST", "PUT"].includes(request.method.toUpperCase())) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return request;
|
||||
}
|
||||
|
||||
const result = sanitizeVscodeRequestBody(body);
|
||||
if (!result.changed) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("content-length");
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: JSON.stringify(result.body),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Alias Ollama compatível para chat no namespace raw.
|
||||
* Apenas reexporta a rota base dessa compatibilidade.
|
||||
*/
|
||||
export { POST, OPTIONS } from "@/app/api/v1/api/chat/route";
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/api/chat/route";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return basePost(await withSanitizedPathTokenApiKey(request));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/responses/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sanitizeVscodeRequest } from "@/app/api/v1/vscode/contextSanitizer";
|
||||
|
||||
function inferTokenFromVscodePath(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
@@ -47,4 +49,8 @@ export function withPathTokenApiKey(request: Request, token?: string) {
|
||||
}
|
||||
|
||||
return new Request(request.url, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withSanitizedPathTokenApiKey(request: Request, token?: string) {
|
||||
return sanitizeVscodeRequest(withPathTokenApiKey(request, token));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route";
|
||||
import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants";
|
||||
import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
import { withSanitizedPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest";
|
||||
|
||||
export { OPTIONS };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorizedRequest = withPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
const authorizedRequest = await withSanitizedPathTokenApiKey(request);
|
||||
return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest));
|
||||
}
|
||||
|
||||
156
tests/unit/vscode-context-sanitizer.test.ts
Normal file
156
tests/unit/vscode-context-sanitizer.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { sanitizeVscodeRequestBody, sanitizeVscodeRequest } =
|
||||
await import("../../src/app/api/v1/vscode/contextSanitizer.ts");
|
||||
|
||||
delete process.env.OMNIROUTE_VSCODE_SANITIZE_CONTEXT;
|
||||
|
||||
type FileAttachment = {
|
||||
filePath: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type TestMessage = {
|
||||
role: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type TestPayload = {
|
||||
attachments?: FileAttachment[];
|
||||
editorContext?: FileAttachment;
|
||||
messages?: TestMessage[];
|
||||
};
|
||||
|
||||
test("vscode sanitizer preserves explicit attachments and strips implicit editor context", () => {
|
||||
const result = sanitizeVscodeRequestBody({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Review the attached Dockerfile.hermes.",
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
filePath: "/repo/Dockerfile.hermes",
|
||||
content: "FROM nousresearch/hermes-agent:latest",
|
||||
},
|
||||
],
|
||||
editorContext: {
|
||||
filePath: "/repo/hermes-bootstrap.sh",
|
||||
content: "#!/usr/bin/env bash\necho should-not-forward",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
const sanitizedBody = result.body as TestPayload;
|
||||
assert.equal(sanitizedBody.attachments?.[0].filePath, "/repo/Dockerfile.hermes");
|
||||
assert.equal(sanitizedBody.attachments?.[0].content.includes("nousresearch"), true);
|
||||
assert.equal("editorContext" in sanitizedBody, false);
|
||||
assert.deepEqual(result.audit.removedImplicitKeys, ["editorContext"]);
|
||||
});
|
||||
|
||||
test("vscode sanitizer redacts sensitive explicit file contents", () => {
|
||||
const result = sanitizeVscodeRequestBody({
|
||||
attachments: [
|
||||
{
|
||||
filePath: "/repo/local-docs/server-access.md",
|
||||
content: "ssh password and production host details",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
const sanitizedBody = result.body as TestPayload;
|
||||
assert.equal(sanitizedBody.attachments?.[0].filePath, "/repo/local-docs/server-access.md");
|
||||
assert.equal(sanitizedBody.attachments?.[0].content, "[REDACTED SENSITIVE CONTEXT]");
|
||||
assert.deepEqual(result.audit.redactedSensitivePaths, ["/repo/local-docs/server-access.md"]);
|
||||
});
|
||||
|
||||
test("vscode sanitizer removes implicit editor text blocks unless they reference explicit files", () => {
|
||||
const result = sanitizeVscodeRequestBody({
|
||||
attachments: [
|
||||
{
|
||||
filePath: "/repo/Dockerfile.hermes",
|
||||
content: "FROM alpine",
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Current file:\n/repo/hermes-bootstrap.sh\nsecret bootstrap content\n\nUser request:\nReview the Dockerfile.hermes",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
const sanitizedBody = result.body as TestPayload;
|
||||
assert.equal(sanitizedBody.messages?.[0].content.includes("hermes-bootstrap.sh"), false);
|
||||
assert.equal(sanitizedBody.messages?.[0].content.includes("User request:"), true);
|
||||
});
|
||||
|
||||
test("vscode sanitizer leaves ordinary chat payloads unchanged", () => {
|
||||
const payload = {
|
||||
model: "gpt-5.5-medium",
|
||||
messages: [{ role: "user", content: "Explain Dockerfile best practices." }],
|
||||
stream: true,
|
||||
};
|
||||
const result = sanitizeVscodeRequestBody(payload);
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.body, payload);
|
||||
assert.deepEqual(result.audit.removedImplicitKeys, []);
|
||||
assert.deepEqual(result.audit.redactedSensitivePaths, []);
|
||||
});
|
||||
|
||||
test("vscode request sanitizer rewrites JSON body and keeps auth headers", async () => {
|
||||
const request = new Request("http://localhost/api/v1/vscode/token/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "test-key",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", content: "Analyze attachment" }],
|
||||
editorContext: { filePath: "/repo/open-file.ts", content: "not explicit" },
|
||||
}),
|
||||
});
|
||||
|
||||
const sanitizedRequest = await sanitizeVscodeRequest(request);
|
||||
const body = (await sanitizedRequest.json()) as TestPayload;
|
||||
|
||||
assert.equal(sanitizedRequest.headers.get("x-api-key"), "test-key");
|
||||
assert.equal("editorContext" in body, false);
|
||||
});
|
||||
|
||||
test("vscode sanitizer preserves ordinary prose mentioning credentials/secrets/kubeconfig", () => {
|
||||
// Regression: the sensitive-path keyword patterns (credentials/secrets/kubeconfig)
|
||||
// must NOT redact free-form chat text. They only flag sensitive *file paths* at the
|
||||
// object level — applying them to prose corrupted legitimate coding prompts.
|
||||
const prose =
|
||||
"How do I store API credentials and secrets in production, and edit the kubeconfig?";
|
||||
const payload = {
|
||||
messages: [{ role: "user", content: prose }],
|
||||
};
|
||||
const result = sanitizeVscodeRequestBody(payload);
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
const sanitizedBody = result.body as TestPayload;
|
||||
assert.equal(sanitizedBody.messages?.[0].content, prose);
|
||||
assert.equal(sanitizedBody.messages?.[0].content.includes("[REDACTED"), false);
|
||||
assert.deepEqual(result.audit.redactedSensitivePaths, []);
|
||||
});
|
||||
|
||||
test("vscode sanitizer still redacts sensitive file paths referenced in object content", () => {
|
||||
// The object-level path+content redaction must keep working after the text-scan
|
||||
// narrowing — a structured attachment whose path matches a keyword pattern is redacted.
|
||||
const result = sanitizeVscodeRequestBody({
|
||||
attachments: [{ filePath: "/home/user/.aws/credentials", content: "aws_secret_access_key=..." }],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
const sanitizedBody = result.body as TestPayload;
|
||||
assert.equal(sanitizedBody.attachments?.[0].content, "[REDACTED SENSITIVE CONTEXT]");
|
||||
assert.deepEqual(result.audit.redactedSensitivePaths, ["/home/user/.aws/credentials"]);
|
||||
});
|
||||
Reference in New Issue
Block a user