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:
Felipe Almeman
2026-06-17 21:45:43 -03:00
committed by GitHub
parent f31e27bdc9
commit cf716e97de
14 changed files with 502 additions and 26 deletions

View File

@@ -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
# ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -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 |

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View 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),
});
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View 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"]);
});