diff --git a/packages/kilo-ui/src/styles/vscode-bridge.css b/packages/kilo-ui/src/styles/vscode-bridge.css index 9e047eace2..c28c79ccf3 100644 --- a/packages/kilo-ui/src/styles/vscode-bridge.css +++ b/packages/kilo-ui/src/styles/vscode-bridge.css @@ -11,8 +11,35 @@ */ html[data-theme="kilo-vscode"] { - /* Let VS Code control the color scheme instead of the OS preference */ - color-scheme: normal; + /* ===== Typography ===== + * opencode UI components use --font-family-sans/mono variables. + * Bridge them to VS Code-native font settings inside the extension. + */ + --font-family-sans: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + --font-family-sans--font-feature-settings: normal; + --font-family-sans--font-variation-settings: normal; + --font-family-mono: var( + --vscode-editor-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + "Courier New", + monospace + ); + --font-family-mono--font-feature-settings: normal; + --font-family-mono--font-variation-settings: normal; + --font-size-small: calc(var(--vscode-font-size, 13px) * 0.85); + --font-size-base: var(--vscode-font-size, 13px); + --font-size-large: calc(var(--vscode-font-size, 13px) * 1.15); + --font-size-x-large: calc(var(--vscode-font-size, 13px) * 1.35); + --font-weight-regular: var(--vscode-font-weight, 400); + --font-weight-medium: 500; + --line-height-large: 150%; + --line-height-x-large: 170%; + --line-height-2x-large: 190%; /* ===== Backgrounds ===== */ --background-base: var(--vscode-editor-background); diff --git a/packages/kilo-vscode/src/utils/logger.ts b/packages/kilo-vscode/src/utils/logger.ts new file mode 100644 index 0000000000..a812a466df --- /dev/null +++ b/packages/kilo-vscode/src/utils/logger.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode" +import { inspect } from "node:util" + +type LogLevel = "debug" | "info" | "warn" | "error" + +// Central logger keeps console output while also writing to a VS Code output channel. +let outputChannel: vscode.OutputChannel | undefined +let debugEnabled = false + +export function initializeLogger(channel: vscode.OutputChannel): void { + outputChannel = channel +} + +export function setLoggerDebugEnabled(enabled: boolean): void { + debugEnabled = enabled +} + +function formatArg(value: unknown): string { + if (typeof value === "string") { + return value + } + return inspect(value, { depth: 6, colors: false, compact: true, breakLength: 120 }) +} + +function consoleMethod(level: LogLevel): (...data: unknown[]) => void { + switch (level) { + case "error": + return console.error + case "warn": + return console.warn + default: + return console.log + } +} + +function write(level: LogLevel, ...args: unknown[]): void { + if (args.length === 0) { + return + } + if (level === "debug" && !debugEnabled) { + return + } + + const timestamp = new Date().toISOString() + const message = args.map((arg) => formatArg(arg)).join(" ") + outputChannel?.appendLine(`[${timestamp}] [${level.toUpperCase()}] ${message}`) + consoleMethod(level)(...args) +} + +export const logger = { + debug: (...args: unknown[]) => write("debug", ...args), + info: (...args: unknown[]) => write("info", ...args), + warn: (...args: unknown[]) => write("warn", ...args), + error: (...args: unknown[]) => write("error", ...args), +} diff --git a/packages/kilo-vscode/src/utils/open-external.ts b/packages/kilo-vscode/src/utils/open-external.ts new file mode 100644 index 0000000000..2cb2c0ca04 --- /dev/null +++ b/packages/kilo-vscode/src/utils/open-external.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +export const ALLOWED_OPEN_EXTERNAL_SCHEMES = new Set(["https:", "vscode:"]) + +export function parseAllowedOpenExternalUrl(rawUrl: unknown): string | null { + const parsedInput = z.string().trim().min(1).safeParse(rawUrl) + if (!parsedInput.success) { + return null + } + + let parsedUrl: URL + try { + parsedUrl = new URL(parsedInput.data) + } catch { + return null + } + + if (!ALLOWED_OPEN_EXTERNAL_SCHEMES.has(parsedUrl.protocol)) { + return null + } + + return parsedUrl.toString() +} diff --git a/packages/kilo-vscode/src/utils/path-security.ts b/packages/kilo-vscode/src/utils/path-security.ts new file mode 100644 index 0000000000..be844b1307 --- /dev/null +++ b/packages/kilo-vscode/src/utils/path-security.ts @@ -0,0 +1,31 @@ +import fs from "node:fs/promises" +import path from "node:path" + +export async function realpathOrResolved(targetPath: string): Promise { + try { + return await fs.realpath(targetPath) + } catch { + return path.resolve(targetPath) + } +} + +export function normalizePathForCompare(targetPath: string): string { + const resolved = path.resolve(targetPath) + return process.platform === "win32" ? resolved.toLowerCase() : resolved +} + +export async function isPathInsideAnyRoot(candidatePath: string, roots: readonly string[]): Promise { + const candidateCanonical = normalizePathForCompare(await realpathOrResolved(candidatePath)) + + for (const root of roots) { + if (!root) { + continue + } + const rootCanonical = normalizePathForCompare(await realpathOrResolved(root)) + if (candidateCanonical === rootCanonical || candidateCanonical.startsWith(`${rootCanonical}${path.sep}`)) { + return true + } + } + + return false +} diff --git a/packages/kilo-vscode/tests/unit/open-external-url.test.ts b/packages/kilo-vscode/tests/unit/open-external-url.test.ts new file mode 100644 index 0000000000..63b3dd0248 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/open-external-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test" +import { parseAllowedOpenExternalUrl } from "../../src/utils/open-external" + +describe("parseAllowedOpenExternalUrl", () => { + it("accepts https urls", () => { + expect(parseAllowedOpenExternalUrl("https://example.com/path?q=1")).toBe("https://example.com/path?q=1") + }) + + it("accepts vscode urls", () => { + expect(parseAllowedOpenExternalUrl("vscode://file/c:/tmp/foo.ts")).toBe("vscode://file/c:/tmp/foo.ts") + }) + + it("rejects unsupported schemes", () => { + expect(parseAllowedOpenExternalUrl("javascript:alert(1)")).toBeNull() + expect(parseAllowedOpenExternalUrl("file:///tmp/a.txt")).toBeNull() + }) + + it("rejects invalid payloads", () => { + expect(parseAllowedOpenExternalUrl("")).toBeNull() + expect(parseAllowedOpenExternalUrl("not-a-url")).toBeNull() + expect(parseAllowedOpenExternalUrl(undefined)).toBeNull() + expect(parseAllowedOpenExternalUrl(42)).toBeNull() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/path-security.test.ts b/packages/kilo-vscode/tests/unit/path-security.test.ts new file mode 100644 index 0000000000..3fb707dfe4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/path-security.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, expect, it } from "bun:test" +import { isPathInsideAnyRoot } from "../../src/utils/path-security" + +describe("isPathInsideAnyRoot", () => { + it("allows paths inside the declared root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-path-security-")) + const nested = path.join(tempRoot, "a", "b", "file.txt") + await fs.mkdir(path.dirname(nested), { recursive: true }) + await fs.writeFile(nested, "ok", "utf8") + + expect(await isPathInsideAnyRoot(nested, [tempRoot])).toBe(true) + }) + + it("rejects traversal outside the declared root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-path-security-")) + const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-path-security-outside-")) + const outsideFile = path.join(outsideRoot, "outside.txt") + await fs.writeFile(outsideFile, "outside", "utf8") + + const traversal = path.join(tempRoot, "..", path.basename(outsideRoot), "outside.txt") + expect(await isPathInsideAnyRoot(traversal, [tempRoot])).toBe(false) + }) + + it("rejects symlink escapes", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-path-security-")) + const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-path-security-outside-")) + const outsideFile = path.join(outsideRoot, "outside.txt") + await fs.writeFile(outsideFile, "outside", "utf8") + + const symlinkPath = path.join(tempRoot, "escape-link") + try { + await fs.symlink(outsideRoot, symlinkPath) + } catch (error) { + if (process.platform === "win32") { + // Windows CI environments may block symlink creation depending on privileges. + return + } + throw error + } + const escapedFile = path.join(symlinkPath, "outside.txt") + + expect(await isPathInsideAnyRoot(escapedFile, [tempRoot])).toBe(false) + }) +})