fix(vscode): harden marketplace skill installs

This commit is contained in:
marius-kilocode
2026-06-02 10:18:30 +02:00
parent 491e97ca0d
commit ed3e1ac99b
3 changed files with 216 additions and 30 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Marketplace skill installation resilient to missing project directories and overlapping install attempts.
@@ -1,6 +1,7 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import { randomUUID } from "crypto"
import * as yaml from "yaml"
import { exec } from "../../util/process"
import type {
@@ -100,7 +101,7 @@ export class MarketplaceInstaller {
await fs.mkdir(dir, { recursive: true })
const filepath = path.join(dir, `${item.id}.md`)
if (!path.resolve(filepath).startsWith(path.resolve(dir))) {
if (!contains(dir, filepath)) {
return { success: false, slug: item.id, error: "Invalid agent id" }
}
@@ -132,13 +133,17 @@ export class MarketplaceInstaller {
scope: "project" | "global",
workspace?: string,
): Promise<RemoveResult> {
if (scope === "project" && !workspace) {
return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" }
}
if (!isSafeId(item.id)) {
return { success: false, slug: item.id, error: "Invalid agent id" }
}
const dir = this.paths.agentsDir(scope, workspace)
const filepath = path.join(dir, `${item.id}.md`)
if (!path.resolve(filepath).startsWith(path.resolve(dir))) {
if (!contains(dir, filepath)) {
return { success: false, slug: item.id, error: "Invalid agent id" }
}
@@ -168,6 +173,10 @@ export class MarketplaceInstaller {
scope: "project" | "global",
workspace?: string,
): Promise<InstallResult> {
if (scope === "project" && !workspace) {
return { success: false, slug: item.id, error: "No workspace directory for project-scope install" }
}
if (!item.content) {
return { success: false, slug: item.id, error: "Skill has no tarball URL" }
}
@@ -178,22 +187,18 @@ export class MarketplaceInstaller {
const base = this.paths.skillsDir(scope, workspace)
const dir = path.join(base, item.id)
if (!path.resolve(dir).startsWith(path.resolve(base))) {
if (!contains(base, dir)) {
return { success: false, slug: item.id, error: "Invalid skill id" }
}
try {
await fs.access(dir)
if (await exists(dir)) {
return { success: false, slug: item.id, error: "Skill already installed. Uninstall it before installing again." }
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
}
const stamp = Date.now()
const tarball = path.join(os.tmpdir(), `kilo-skill-${item.id}-${stamp}.tar.gz`)
// Stage under `base` (not os.tmpdir()) so fs.rename() never crosses filesystems (EXDEV).
await fs.mkdir(base, { recursive: true })
const staging = path.join(base, `.staging-${item.id}-${stamp}`)
const staging = await fs.mkdtemp(path.join(base, `.staging-${item.id}-`))
const tarball = path.join(os.tmpdir(), `kilo-skill-${item.id}-${randomUUID()}.tar.gz`)
try {
const response = await fetch(item.content)
@@ -203,14 +208,11 @@ export class MarketplaceInstaller {
const buffer = Buffer.from(await response.arrayBuffer())
await fs.writeFile(tarball, buffer)
await fs.mkdir(staging, { recursive: true })
await exec("tar", ["-xzf", tarball, "--strip-components=1", "-C", staging])
const escaped = await findEscapedPaths(staging)
if (escaped.length > 0) {
console.warn(`Skill archive ${item.id} contains escaped paths:`, escaped)
await fs.rm(staging, { recursive: true })
return { success: false, slug: item.id, error: "Skill archive contains unsafe paths" }
}
@@ -218,7 +220,6 @@ export class MarketplaceInstaller {
await fs.access(path.join(staging, "SKILL.md"))
} catch {
console.warn(`Extracted skill ${item.id} missing SKILL.md, rolling back`)
await fs.rm(staging, { recursive: true })
return { success: false, slug: item.id, error: "Extracted archive missing SKILL.md" }
}
@@ -226,19 +227,24 @@ export class MarketplaceInstaller {
return { success: true, slug: item.id, filePath: path.join(dir, "SKILL.md"), line: 1 }
} catch (err) {
console.warn(`Failed to install skill ${item.id}:`, err)
try {
await fs.rm(staging, { recursive: true })
} catch {
console.warn(`Failed to clean up staging directory ${staging}`)
if (await exists(dir)) {
return {
success: false,
slug: item.id,
error: "Skill already installed. Uninstall it before installing again.",
}
}
console.warn(`Failed to install skill ${item.id}:`, err)
return { success: false, slug: item.id, error: String(err) }
} finally {
try {
await fs.unlink(tarball)
} catch {
console.warn(`Failed to clean up temp file ${tarball}`)
}
await Promise.all([
fs.rm(staging, { recursive: true, force: true }).catch((err) => {
console.warn(`Failed to clean up staging directory ${staging}:`, err)
}),
fs.rm(tarball, { force: true }).catch((err) => {
console.warn(`Failed to clean up temp file ${tarball}:`, err)
}),
])
}
}
@@ -251,6 +257,10 @@ export class MarketplaceInstaller {
}
async removeMcp(item: McpMarketplaceItem, scope: "project" | "global", workspace?: string): Promise<RemoveResult> {
if (scope === "project" && !workspace) {
return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" }
}
const config = await this.readConfig(scope, workspace)
if (!config.mcp?.[item.id]) {
return { success: true, slug: item.id }
@@ -266,12 +276,16 @@ export class MarketplaceInstaller {
scope: "project" | "global",
workspace?: string,
): Promise<RemoveResult> {
if (scope === "project" && !workspace) {
return { success: false, slug: item.id, error: "No workspace directory for project-scope removal" }
}
if (!isSafeId(item.id)) {
return { success: false, slug: item.id, error: "Invalid skill id" }
}
const base = this.paths.skillsDir(scope, workspace)
const dir = path.join(base, item.id)
if (!path.resolve(dir).startsWith(path.resolve(base))) {
if (!contains(base, dir)) {
return { success: false, slug: item.id, error: "Invalid skill id" }
}
try {
@@ -316,6 +330,20 @@ export class MarketplaceInstaller {
// ── Helpers ─────────────────────────────────────────────────────────
async function exists(filepath: string): Promise<boolean> {
try {
await fs.access(filepath)
return true
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false
throw err
}
}
function contains(dir: string, filepath: string): boolean {
return path.resolve(filepath).startsWith(path.resolve(dir) + path.sep)
}
/**
* Normalize a marketplace MCP entry from the old Kilocode format to the CLI's expected format.
*
@@ -359,7 +387,8 @@ function normalizeMcpEntry(raw: Record<string, unknown>): Record<string, unknown
}
function isSafeId(id: string): boolean {
if (!id || id.includes("..") || id.includes("/") || id.includes("\\")) return false
if (!id || id === "." || id.includes("..") || id.includes("/") || id.includes("\\") || id.endsWith(".")) return false
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(id)) return false
return /^[\w\-@.]+$/.test(id)
}
@@ -4,6 +4,7 @@ import * as os from "os"
import * as path from "path"
import { MarketplaceInstaller } from "../../src/services/marketplace/installer"
import { MarketplacePaths } from "../../src/services/marketplace/paths"
import { exec } from "../../src/util/process"
const tmpDir = path.join(os.tmpdir(), `kilo-test-${Date.now()}`)
@@ -17,11 +18,36 @@ class TestPaths extends MarketplacePaths {
}
}
describe("MarketplaceInstaller MCP format normalization", () => {
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true }).catch(() => {})
})
function skill(content: string, id = "test-skill") {
return {
type: "skill" as const,
id,
name: "Test Skill",
description: "test",
category: "test",
githubUrl: "https://example.com",
content,
displayName: "Test Skill",
displayCategory: "Test",
}
}
async function archive(): Promise<Buffer> {
const root = path.join(tmpDir, "archive")
const source = path.join(root, "source")
const dir = path.join(source, "skill")
const tarball = path.join(root, "skill.tar.gz")
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(path.join(dir, "SKILL.md"), "# Test Skill\n")
await exec("tar", ["-czf", tarball, "-C", source, "skill"])
return fs.readFile(tarball)
}
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe("MarketplaceInstaller MCP format normalization", () => {
it("converts local command+args+env format to CLI format", async () => {
const installer = new MarketplaceInstaller(new TestPaths())
const item = {
@@ -94,3 +120,129 @@ describe("MarketplaceInstaller MCP format normalization", () => {
expect(mcp).toEqual({ type: "local", command: ["npx", "-y", "someserver"], environment: { KEY: "val" } })
})
})
describe("MarketplaceInstaller skills", () => {
it("rejects project installs without a workspace directory", async () => {
const installer = new MarketplaceInstaller(new TestPaths())
const result = await installer.installSkill(skill("https://example.com/skill.tar.gz"), "project")
expect(result).toEqual({
success: false,
slug: "test-skill",
error: "No workspace directory for project-scope install",
})
})
it("rejects project removals without a workspace directory", async () => {
const installer = new MarketplaceInstaller(new TestPaths())
const result = await installer.removeSkill(skill("https://example.com/skill.tar.gz"), "project")
expect(result).toEqual({
success: false,
slug: "test-skill",
error: "No workspace directory for project-scope removal",
})
})
it("rejects project MCP and agent removals without a workspace directory", async () => {
const installer = new MarketplaceInstaller(new TestPaths())
const results = await Promise.all([
installer.remove(
{
type: "mcp",
id: "test-mcp",
name: "Test MCP",
description: "test",
url: "https://example.com",
content: "{}",
},
"project",
),
installer.remove(
{
type: "agent",
id: "test-agent",
name: "Test Agent",
description: "test",
content: { mode: "all", description: "test", prompt: "test" },
},
"project",
),
])
expect(results).toEqual([
{ success: false, slug: "test-mcp", error: "No workspace directory for project-scope removal" },
{ success: false, slug: "test-agent", error: "No workspace directory for project-scope removal" },
])
})
it("rejects skill ids that are unsafe on supported filesystems", async () => {
const paths = new TestPaths()
const dir = path.join(paths.skillsDir("project", tmpDir), "installed")
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(path.join(dir, "SKILL.md"), "# Installed\n")
const installer = new MarketplaceInstaller(paths)
for (const id of [".", "installed.", "CON", "nul.txt"]) {
const result = await installer.removeSkill(skill("https://example.com/skill.tar.gz", id), "project", tmpDir)
expect(result).toEqual({ success: false, slug: id, error: "Invalid skill id" })
}
expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf-8")).toBe("# Installed\n")
})
it("installs an extracted project skill without leaving staging directories", async () => {
const buffer = await archive()
const url = `data:application/gzip;base64,${buffer.toString("base64")}`
const paths = new TestPaths()
const installer = new MarketplaceInstaller(paths)
const result = await installer.installSkill(skill(url), "project", tmpDir)
expect(result.success).toBe(true)
expect(await fs.readFile(path.join(paths.skillsDir("project", tmpDir), "test-skill", "SKILL.md"), "utf-8")).toBe(
"# Test Skill\n",
)
expect(
(await fs.readdir(paths.skillsDir("project", tmpDir))).filter((name) => name.startsWith(".staging-")),
).toEqual([])
})
it("handles concurrent installs without sharing temporary paths", async () => {
const buffer = await archive()
const original = globalThis.fetch
const now = Date.now
const paths = new TestPaths()
const installer = new MarketplaceInstaller(paths)
const item = skill("https://example.com/skill.tar.gz")
const gate = Promise.withResolvers<void>()
let count = 0
Date.now = () => 1
globalThis.fetch = async () => {
count += 1
if (count === 2) gate.resolve()
await gate.promise
return new Response(buffer)
}
try {
const results = await Promise.all([
installer.installSkill(item, "project", tmpDir),
installer.installSkill(item, "project", tmpDir),
])
expect(count).toBe(2)
expect(results.filter((result) => result.success)).toHaveLength(1)
expect(results.find((result) => !result.success)?.error).toBe(
"Skill already installed. Uninstall it before installing again.",
)
expect(await fs.readFile(path.join(paths.skillsDir("project", tmpDir), "test-skill", "SKILL.md"), "utf-8")).toBe(
"# Test Skill\n",
)
expect(
(await fs.readdir(paths.skillsDir("project", tmpDir))).filter((name) => name.startsWith(".staging-")),
).toEqual([])
} finally {
globalThis.fetch = original
Date.now = now
}
})
})