Skip to content

Commit 6f0e21e

Browse files
kulvirgitclaude
andcommitted
fix: address code review findings for external skill discovery
Security fixes: - Reject symlinked skill files (lstat check) — prevents arbitrary file read - Add prototype pollution guards (__proto__, constructor, prototype) - Reject skill names containing .. segments (path traversal) - Add duplicate name warnings in external discovery path Bug fixes: - Gemini TOML nested commands now preserve directory path (team/review instead of just review) — parity with markdown command handling - TOML parse errors logged at warn level instead of debug - setSkillDiscoveryResult clears state on empty input (prevents stale data) Test improvements: - Isolated home directory (hermetic tests, no real ~ leakage) - Added adversarial tests: prototype pollution, path traversal, symlinks - Added discovery result lifecycle tests (set/consume/clear) - Added nested TOML path preservation test - Added home directory isolation test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4fc0e6 commit 6f0e21e

2 files changed

Lines changed: 215 additions & 18 deletions

File tree

packages/opencode/src/skill/discover-external.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// altimate_change start — auto-discover skills/commands from external AI tool configs
22
import path from "path"
3+
import fs from "fs/promises"
34
import { pathToFileURL } from "url"
45
import { Log } from "../util/log"
56
import { Filesystem } from "../util/filesystem"
@@ -19,13 +20,18 @@ interface ExternalSkillSource {
1920
format: "skill-md" | "command-md" | "command-toml"
2021
}
2122

23+
// Discovery priority: Claude Code → Codex → Gemini (skills) → Gemini (commands).
24+
// Within each source: project-deep → project-shallow → home. First skill with a given name wins.
2225
const SOURCES: ExternalSkillSource[] = [
2326
{ tool: "claude-code", dir: ".claude", pattern: "commands/**/*.md", scope: "both", format: "command-md" },
2427
{ tool: "codex", dir: ".codex", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" },
2528
{ tool: "gemini", dir: ".gemini", pattern: "skills/**/SKILL.md", scope: "both", format: "skill-md" },
2629
{ tool: "gemini", dir: ".gemini", pattern: "commands/**/*.toml", scope: "both", format: "command-toml" },
2730
]
2831

32+
// Names that would pollute Object.prototype — must never be used as skill keys
33+
const POISONED_NAMES = new Set(["__proto__", "constructor", "prototype"])
34+
2935
/**
3036
* Parse a standard SKILL.md file (Codex, Gemini) using ConfigMarkdown.parse().
3137
* Returns a Skill.Info or undefined if the file is malformed.
@@ -87,17 +93,20 @@ async function transformCommandMd(filePath: string, commandsRoot: string): Promi
8793
* Expects `prompt` field for content, optional `description`.
8894
* Converts `{{args}}` / `{{ args }}` → `$ARGUMENTS`.
8995
*/
90-
async function transformCommandToml(filePath: string): Promise<Skill.Info | undefined> {
96+
async function transformCommandToml(filePath: string, commandsRoot: string): Promise<Skill.Info | undefined> {
9197
try {
98+
// Bun-specific: native TOML import support via import attributes (not available in Node.js)
9299
const mod = await import(pathToFileURL(filePath).href, { with: { type: "toml" } })
93100
const data = (mod.default || mod) as Record<string, unknown>
94101

95102
if (typeof data.prompt !== "string" || !data.prompt.trim()) {
96-
log.debug("TOML command missing prompt field", { path: filePath })
103+
log.warn("TOML command missing prompt field", { path: filePath })
97104
return undefined
98105
}
99106

100-
const name = path.basename(filePath, ".toml")
107+
// Derive name from relative path (preserving nested directories), matching transformCommandMd
108+
const rel = path.relative(commandsRoot, filePath)
109+
const name = rel.replace(/\.toml$/i, "").replace(/\\/g, "/")
101110
const description = typeof data.description === "string" ? data.description : ""
102111
// Convert Gemini's {{args}} / {{ args }} placeholder to $ARGUMENTS
103112
const content = data.prompt.replace(/\{\{\s*args\s*\}\}/g, "$ARGUMENTS")
@@ -109,7 +118,7 @@ async function transformCommandToml(filePath: string): Promise<Skill.Info | unde
109118
content,
110119
}
111120
} catch (err) {
112-
log.debug("failed to parse TOML command", { path: filePath, err })
121+
log.warn("failed to parse TOML command", { path: filePath, err })
113122
return undefined
114123
}
115124
}
@@ -129,11 +138,21 @@ async function scanSource(
129138
absolute: true,
130139
include: "file",
131140
dot: true,
132-
symlink: true,
141+
symlink: false, // Security: don't follow symlinks — prevents reading arbitrary files via crafted repos
133142
}).catch(() => [] as string[])
134143

135144
const results: Skill.Info[] = []
136145
for (const match of matches) {
146+
// Security: reject symlinks — prevents reading arbitrary files via crafted repos
147+
try {
148+
const stat = await fs.lstat(match)
149+
if (stat.isSymbolicLink()) {
150+
log.warn("skipping symlinked skill file", { path: match })
151+
continue
152+
}
153+
} catch {
154+
continue
155+
}
137156
let skill: Skill.Info | undefined
138157
switch (source.format) {
139158
case "skill-md":
@@ -143,7 +162,7 @@ async function scanSource(
143162
skill = await transformCommandMd(match, path.join(baseDir, "commands"))
144163
break
145164
case "command-toml":
146-
skill = await transformCommandToml(match)
165+
skill = await transformCommandToml(match, path.join(baseDir, "commands"))
147166
break
148167
}
149168
if (skill) results.push(skill)
@@ -158,20 +177,33 @@ async function scanSource(
158177
* Searches both home directory and project directory (walking up from CWD to worktree root).
159178
* Returns discovered skills and contributing source labels.
160179
*/
161-
export async function discoverExternalSkills(worktree: string): Promise<{
180+
export async function discoverExternalSkills(worktree: string, homeDir?: string): Promise<{
162181
skills: Skill.Info[]
163182
sources: string[]
164183
}> {
165184
log.info("Discovering skills/commands from external AI tool configs...")
166185
const allSkills: Skill.Info[] = []
167186
const sources: string[] = []
168187
const seen = new Set<string>()
169-
const homedir = Global.Path.home
188+
const homedir = homeDir ?? Global.Path.home
170189

171190
const addSkills = (skills: Skill.Info[], sourceLabel: string) => {
172191
let added = 0
173192
for (const skill of skills) {
174-
if (seen.has(skill.name)) continue
193+
// Guard against prototype pollution
194+
if (POISONED_NAMES.has(skill.name)) {
195+
log.warn("rejecting skill with reserved name", { name: skill.name, source: sourceLabel })
196+
continue
197+
}
198+
// Reject path traversal in derived names
199+
if (skill.name.includes("..")) {
200+
log.warn("rejecting skill with path traversal in name", { name: skill.name, source: sourceLabel })
201+
continue
202+
}
203+
if (seen.has(skill.name)) {
204+
log.warn("duplicate external skill name, skipping", { name: skill.name, source: sourceLabel, existing: allSkills.find((s) => s.name === skill.name)?.location })
205+
continue
206+
}
175207
seen.add(skill.name)
176208
allSkills.push(skill)
177209
added++
@@ -214,9 +246,7 @@ let _lastDiscovery: { skillNames: string[]; sources: string[] } | null = null
214246

215247
/** Called from skill.ts after merge with only the names that were actually added. */
216248
export function setSkillDiscoveryResult(skillNames: string[], sources: string[]) {
217-
if (skillNames.length > 0) {
218-
_lastDiscovery = { skillNames, sources }
219-
}
249+
_lastDiscovery = skillNames.length > 0 ? { skillNames, sources } : null
220250
}
221251

222252
/** Returns and clears the last discovery result (for one-time notification). */

packages/opencode/test/skill/discover-external.test.ts

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2-
import { mkdtemp, rm, mkdir, writeFile } from "fs/promises"
2+
import { mkdtemp, rm, mkdir, writeFile, symlink } from "fs/promises"
33
import { tmpdir } from "os"
44
import path from "path"
5-
import { discoverExternalSkills } from "../../src/skill/discover-external"
5+
import {
6+
discoverExternalSkills,
7+
setSkillDiscoveryResult,
8+
consumeSkillDiscoveryResult,
9+
} from "../../src/skill/discover-external"
610
import { Instance } from "../../src/project/instance"
711

812
let tempDir: string
13+
let homeDir: string
914

1015
beforeEach(async () => {
1116
tempDir = await mkdtemp(path.join(tmpdir(), "skill-discover-"))
17+
homeDir = await mkdtemp(path.join(tmpdir(), "skill-discover-home-"))
1218
})
1319

1420
afterEach(async () => {
1521
await rm(tempDir, { recursive: true, force: true })
22+
await rm(homeDir, { recursive: true, force: true })
1623
})
1724

1825
describe("discoverExternalSkills", () => {
19-
// Helper to run discovery with tempDir as both worktree and Instance.directory
26+
// Helper to run discovery with tempDir as both worktree and Instance.directory,
27+
// and an isolated homeDir to prevent real home directory from leaking into results
2028
async function discover(worktree?: string) {
2129
return Instance.provide({
2230
directory: worktree ?? tempDir,
23-
fn: () => discoverExternalSkills(worktree ?? tempDir),
31+
fn: () => discoverExternalSkills(worktree ?? tempDir, homeDir),
2432
})
2533
}
2634

@@ -146,6 +154,21 @@ prompt = "Deploy the app to {{ args }} environment"
146154
expect(skill!.content).not.toContain("{{")
147155
})
148156

157+
test("preserves nested TOML command path as name", async () => {
158+
await mkdir(path.join(tempDir, ".gemini", "commands", "team"), { recursive: true })
159+
await writeFile(
160+
path.join(tempDir, ".gemini", "commands", "team", "deploy.toml"),
161+
`description = "Team deploy"
162+
prompt = "Deploy for team"
163+
`,
164+
)
165+
166+
const { skills } = await discover()
167+
const skill = skills.find((s) => s.name === "team/deploy")
168+
expect(skill).toBeDefined()
169+
expect(skill!.description).toBe("Team deploy")
170+
})
171+
149172
test("skips Gemini TOML command without prompt field", async () => {
150173
await mkdir(path.join(tempDir, ".gemini", "commands"), { recursive: true })
151174
await writeFile(
@@ -295,10 +318,9 @@ prompt = "Run {{args}}"
295318
// --- Worktree edge cases ---
296319

297320
test("skips project scan when worktree is /", async () => {
298-
// Should not throw and should return empty (no dirs at /)
299321
const result = await Instance.provide({
300322
directory: tempDir,
301-
fn: () => discoverExternalSkills("/"),
323+
fn: () => discoverExternalSkills("/", homeDir),
302324
})
303325
expect(result.skills).toEqual([])
304326
})
@@ -324,4 +346,149 @@ Content here
324346
expect(skill).toBeDefined()
325347
expect(skill!.location).toBe(cmdPath)
326348
})
349+
350+
// --- Security: Prototype pollution ---
351+
352+
test("rejects skills named __proto__", async () => {
353+
await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true })
354+
await writeFile(
355+
path.join(tempDir, ".claude", "commands", "__proto__.md"),
356+
`---
357+
name: __proto__
358+
description: Malicious skill
359+
---
360+
361+
Exploit content
362+
`,
363+
)
364+
365+
const { skills } = await discover()
366+
expect(skills.find((s) => s.name === "__proto__")).toBeUndefined()
367+
})
368+
369+
test("rejects skills named constructor", async () => {
370+
await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true })
371+
await writeFile(
372+
path.join(tempDir, ".claude", "commands", "constructor.md"),
373+
`---
374+
name: constructor
375+
description: Malicious skill
376+
---
377+
378+
Exploit
379+
`,
380+
)
381+
382+
const { skills } = await discover()
383+
expect(skills.find((s) => s.name === "constructor")).toBeUndefined()
384+
})
385+
386+
test("rejects skills named prototype", async () => {
387+
await mkdir(path.join(tempDir, ".codex", "skills", "proto"), { recursive: true })
388+
await writeFile(
389+
path.join(tempDir, ".codex", "skills", "proto", "SKILL.md"),
390+
`---
391+
name: prototype
392+
description: Malicious
393+
---
394+
395+
Content
396+
`,
397+
)
398+
399+
const { skills } = await discover()
400+
expect(skills.find((s) => s.name === "prototype")).toBeUndefined()
401+
})
402+
403+
// --- Security: Path traversal ---
404+
405+
test("rejects skill names containing .. segments", async () => {
406+
await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true })
407+
await writeFile(
408+
path.join(tempDir, ".claude", "commands", "legit.md"),
409+
`---
410+
name: ../../etc/passwd
411+
description: Path traversal attempt
412+
---
413+
414+
Content
415+
`,
416+
)
417+
418+
const { skills } = await discover()
419+
expect(skills.find((s) => s.name === "../../etc/passwd")).toBeUndefined()
420+
})
421+
422+
// --- Security: Symlinks not followed ---
423+
424+
test("does not follow symlinks to files outside the directory", async () => {
425+
// Create a sensitive file outside the project
426+
const sensitiveDir = await mkdtemp(path.join(tmpdir(), "sensitive-"))
427+
await writeFile(path.join(sensitiveDir, "secret.txt"), "TOP SECRET DATA")
428+
429+
// Create a symlink inside .claude/commands/ pointing to the sensitive file
430+
await mkdir(path.join(tempDir, ".claude", "commands"), { recursive: true })
431+
try {
432+
await symlink(
433+
path.join(sensitiveDir, "secret.txt"),
434+
path.join(tempDir, ".claude", "commands", "steal.md"),
435+
)
436+
} catch {
437+
// symlink may fail on some platforms — skip test
438+
await rm(sensitiveDir, { recursive: true, force: true })
439+
return
440+
}
441+
442+
const { skills } = await discover()
443+
// The symlinked file should NOT be discovered (symlink: false)
444+
expect(skills.find((s) => s.name === "steal")).toBeUndefined()
445+
446+
await rm(sensitiveDir, { recursive: true, force: true })
447+
})
448+
449+
// --- Discovery result lifecycle ---
450+
451+
test("consumeSkillDiscoveryResult returns null when nothing set", () => {
452+
// Clear any prior state
453+
setSkillDiscoveryResult([], [])
454+
const result = consumeSkillDiscoveryResult()
455+
expect(result).toBeNull()
456+
})
457+
458+
test("consumeSkillDiscoveryResult returns result once then null", () => {
459+
setSkillDiscoveryResult(["my-skill"], [".claude/commands"])
460+
const first = consumeSkillDiscoveryResult()
461+
expect(first).toEqual({ skillNames: ["my-skill"], sources: [".claude/commands"] })
462+
const second = consumeSkillDiscoveryResult()
463+
expect(second).toBeNull()
464+
})
465+
466+
test("setSkillDiscoveryResult with empty array clears previous result", () => {
467+
setSkillDiscoveryResult(["old-skill"], [".claude/commands"])
468+
setSkillDiscoveryResult([], [])
469+
const result = consumeSkillDiscoveryResult()
470+
expect(result).toBeNull()
471+
})
472+
473+
// --- Home directory isolation ---
474+
475+
test("discovers skills from home directory separately", async () => {
476+
// Put a skill in the isolated home directory
477+
await mkdir(path.join(homeDir, ".claude", "commands"), { recursive: true })
478+
await writeFile(
479+
path.join(homeDir, ".claude", "commands", "home-cmd.md"),
480+
`---
481+
name: home-cmd
482+
description: Home command
483+
---
484+
485+
Home content
486+
`,
487+
)
488+
489+
const { skills } = await discover()
490+
const skill = skills.find((s) => s.name === "home-cmd")
491+
expect(skill).toBeDefined()
492+
expect(skill!.location).toContain(homeDir)
493+
})
327494
})

0 commit comments

Comments
 (0)