From cf716e97de9dcaa74490968923e05205479ec855 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:45:43 -0300 Subject: [PATCH] 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 --- .env.example | 9 + docs/reference/ENVIRONMENT.md | 6 + .../api/v1/vscode/[token]/api/chat/route.ts | 9 +- .../vscode/[token]/chat/completions/route.ts | 6 +- .../api/v1/vscode/[token]/responses/route.ts | 6 +- .../api/v1/vscode/[token]/tokenizedRequest.ts | 8 +- .../[token]/v1/chat/completions/route.ts | 6 +- src/app/api/v1/vscode/contextSanitizer.ts | 283 ++++++++++++++++++ .../v1/vscode/raw/[token]/api/chat/route.ts | 13 +- .../raw/[token]/chat/completions/route.ts | 6 +- .../v1/vscode/raw/[token]/responses/route.ts | 6 +- .../v1/vscode/raw/[token]/tokenizedRequest.ts | 8 +- .../raw/[token]/v1/chat/completions/route.ts | 6 +- tests/unit/vscode-context-sanitizer.test.ts | 156 ++++++++++ 14 files changed, 502 insertions(+), 26 deletions(-) create mode 100644 src/app/api/v1/vscode/contextSanitizer.ts create mode 100644 tests/unit/vscode-context-sanitizer.test.ts diff --git a/.env.example b/.env.example index 4f6e691e37..5374798848 100644 --- a/.env.example +++ b/.env.example @@ -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 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7b3fed9986..d4b3a4bf5d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 | diff --git a/src/app/api/v1/vscode/[token]/api/chat/route.ts b/src/app/api/v1/vscode/[token]/api/chat/route.ts index 4295e62c16..01f88f3660 100644 --- a/src/app/api/v1/vscode/[token]/api/chat/route.ts +++ b/src/app/api/v1/vscode/[token]/api/chat/route.ts @@ -1 +1,8 @@ -export { POST, OPTIONS } from "@/app/api/v1/api/chat/route"; \ No newline at end of file +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)); +} diff --git a/src/app/api/v1/vscode/[token]/chat/completions/route.ts b/src/app/api/v1/vscode/[token]/chat/completions/route.ts index b243f2fe8b..47718233b4 100644 --- a/src/app/api/v1/vscode/[token]/chat/completions/route.ts +++ b/src/app/api/v1/vscode/[token]/chat/completions/route.ts @@ -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)); } diff --git a/src/app/api/v1/vscode/[token]/responses/route.ts b/src/app/api/v1/vscode/[token]/responses/route.ts index 131c373961..c5f86737d2 100644 --- a/src/app/api/v1/vscode/[token]/responses/route.ts +++ b/src/app/api/v1/vscode/[token]/responses/route.ts @@ -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)); } diff --git a/src/app/api/v1/vscode/[token]/tokenizedRequest.ts b/src/app/api/v1/vscode/[token]/tokenizedRequest.ts index d390cadcd5..24bb41d252 100644 --- a/src/app/api/v1/vscode/[token]/tokenizedRequest.ts +++ b/src/app/api/v1/vscode/[token]/tokenizedRequest.ts @@ -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); -} \ No newline at end of file +} + +export async function withSanitizedPathTokenApiKey(request: Request, token?: string) { + return sanitizeVscodeRequest(withPathTokenApiKey(request, token)); +} diff --git a/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts b/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts index b243f2fe8b..47718233b4 100644 --- a/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts +++ b/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts @@ -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)); } diff --git a/src/app/api/v1/vscode/contextSanitizer.ts b/src/app/api/v1/vscode/contextSanitizer.ts new file mode 100644 index 0000000000..1c202b94be --- /dev/null +++ b/src/app/api/v1/vscode/contextSanitizer.ts @@ -0,0 +1,283 @@ +type JsonObject = Record; + +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 = { + 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() +): Set { + 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, + 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, + 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(body: T): VscodeContextSanitizerResult { + 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 { + 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), + }); +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts b/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts index 76d167cd68..14269e4f2c 100644 --- a/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts @@ -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"; \ No newline at end of file +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)); +} diff --git a/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts b/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts index 3a7abb6ad8..c59f57b066 100644 --- a/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts @@ -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)); } diff --git a/src/app/api/v1/vscode/raw/[token]/responses/route.ts b/src/app/api/v1/vscode/raw/[token]/responses/route.ts index f61e4eedab..f6dfbae2bb 100644 --- a/src/app/api/v1/vscode/raw/[token]/responses/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/responses/route.ts @@ -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)); } diff --git a/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts b/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts index d390cadcd5..24bb41d252 100644 --- a/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts +++ b/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts @@ -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); -} \ No newline at end of file +} + +export async function withSanitizedPathTokenApiKey(request: Request, token?: string) { + return sanitizeVscodeRequest(withPathTokenApiKey(request, token)); +} diff --git a/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts b/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts index 3a7abb6ad8..c59f57b066 100644 --- a/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts @@ -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)); } diff --git a/tests/unit/vscode-context-sanitizer.test.ts b/tests/unit/vscode-context-sanitizer.test.ts new file mode 100644 index 0000000000..f24c490b52 --- /dev/null +++ b/tests/unit/vscode-context-sanitizer.test.ts @@ -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"]); +});