Skip to content
31 changes: 29 additions & 2 deletions packages/kilo-ui/src/styles/vscode-bridge.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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);
Expand Down
55 changes: 55 additions & 0 deletions packages/kilo-vscode/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -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),
}
23 changes: 23 additions & 0 deletions packages/kilo-vscode/src/utils/open-external.ts
Original file line number Diff line number Diff line change
@@ -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()
}
31 changes: 31 additions & 0 deletions packages/kilo-vscode/src/utils/path-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from "node:fs/promises"
import path from "node:path"

export async function realpathOrResolved(targetPath: string): Promise<string> {
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<boolean> {
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
}
24 changes: 24 additions & 0 deletions packages/kilo-vscode/tests/unit/open-external-url.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
47 changes: 47 additions & 0 deletions packages/kilo-vscode/tests/unit/path-security.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading