Skip to content

Commit 61de754

Browse files
fix(cli): harden skill name handling during install (#2597)
* fix(cli): validate skill names to prevent path traversal on install Adds boundary validation and containment checks so a remote SKILL.md with a malicious name field (e.g. `name: ..`) cannot escape the skills root during `ctx7 skills install`. Previously, the value flowed from parseSkillFrontmatter to installSkillFiles unchecked, and the existing traversal guard only verified files stayed inside the attacker-chosen directory rather than the real skills root, enabling arbitrary file writes outside `.claude/skills` (e.g. `.claude/settings.json` for hook-driven RCE). symlinkSkill had the same trust issue and could `rm(recursive: true)` arbitrary directories. * chore: add changeset for skill name validation * chore: soften changeset wording
1 parent c918e80 commit 61de754

6 files changed

Lines changed: 207 additions & 12 deletions

File tree

.changeset/validate-skill-names.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ctx7": patch
3+
---
4+
5+
Harden skill name handling during `ctx7 skills install` and `ctx7 skills remove`. Skill names from remote `SKILL.md` files are now restricted to a safe character set, and the install sinks assert the target directory is a direct child of the skills root before writing.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
2+
import { mkdir, readFile, writeFile, rm, access } from "fs/promises";
3+
import { join } from "path";
4+
import { tmpdir } from "os";
5+
6+
import { installSkillFiles, symlinkSkill } from "../utils/installer.js";
7+
import { isSafeSkillName, assertSkillNameInRoot } from "../utils/skill-name.js";
8+
9+
let tempDir: string;
10+
11+
async function exists(path: string): Promise<boolean> {
12+
try {
13+
await access(path);
14+
return true;
15+
} catch {
16+
return false;
17+
}
18+
}
19+
20+
beforeEach(async () => {
21+
tempDir = join(tmpdir(), `ctx7-installer-test-${Date.now()}-${Math.random()}`);
22+
await mkdir(tempDir, { recursive: true });
23+
});
24+
25+
afterEach(async () => {
26+
await rm(tempDir, { recursive: true, force: true });
27+
});
28+
29+
describe("isSafeSkillName", () => {
30+
test("accepts typical skill names", () => {
31+
for (const name of [
32+
"pdf",
33+
"find-docs",
34+
"skill_one",
35+
"skill.v2",
36+
"a1",
37+
"abc123",
38+
"PDF",
39+
"Find-Docs",
40+
"MySkill",
41+
]) {
42+
expect(isSafeSkillName(name)).toBe(true);
43+
}
44+
});
45+
46+
test("rejects path traversal and separators", () => {
47+
for (const name of [
48+
"..",
49+
".",
50+
"../evil",
51+
"..\\evil",
52+
"a/b",
53+
"a\\b",
54+
"/abs",
55+
"C:\\drive",
56+
"with space",
57+
"",
58+
".hidden",
59+
"name\0",
60+
"name\nwith-newline",
61+
]) {
62+
expect(isSafeSkillName(name)).toBe(false);
63+
}
64+
});
65+
66+
test("rejects names longer than 128 chars", () => {
67+
expect(isSafeSkillName("a".repeat(128))).toBe(true);
68+
expect(isSafeSkillName("a".repeat(129))).toBe(false);
69+
});
70+
});
71+
72+
describe("assertSkillNameInRoot", () => {
73+
test("returns resolved path for safe name", () => {
74+
const result = assertSkillNameInRoot("/tmp/skills", "pdf");
75+
expect(result).toBe("/tmp/skills/pdf");
76+
});
77+
78+
test("throws on traversal", () => {
79+
expect(() => assertSkillNameInRoot("/tmp/skills", "..")).toThrow();
80+
expect(() => assertSkillNameInRoot("/tmp/skills", "../evil")).toThrow();
81+
});
82+
});
83+
84+
describe("installSkillFiles", () => {
85+
test("writes files inside the skill directory", async () => {
86+
const skillsRoot = join(tempDir, "skills");
87+
await mkdir(skillsRoot, { recursive: true });
88+
89+
await installSkillFiles("good", [{ path: "SKILL.md", content: "hello" }], skillsRoot);
90+
91+
const written = await readFile(join(skillsRoot, "good", "SKILL.md"), "utf8");
92+
expect(written).toBe("hello");
93+
});
94+
95+
test("rejects skill name '..' and does not write outside skills root", async () => {
96+
const skillsRoot = join(tempDir, ".claude", "skills");
97+
await mkdir(skillsRoot, { recursive: true });
98+
99+
const settingsPath = join(tempDir, ".claude", "settings.json");
100+
101+
await expect(
102+
installSkillFiles("..", [{ path: "settings.json", content: '{"hooks":{}}' }], skillsRoot)
103+
).rejects.toThrow();
104+
105+
expect(await exists(settingsPath)).toBe(false);
106+
});
107+
108+
test("rejects skill names with path separators", async () => {
109+
const skillsRoot = join(tempDir, "skills");
110+
await mkdir(skillsRoot, { recursive: true });
111+
112+
for (const bad of ["../evil", "a/b", "..\\evil"]) {
113+
await expect(
114+
installSkillFiles(bad, [{ path: "SKILL.md", content: "x" }], skillsRoot)
115+
).rejects.toThrow();
116+
}
117+
});
118+
119+
test("still rejects traversal in file.path", async () => {
120+
const skillsRoot = join(tempDir, "skills");
121+
await mkdir(skillsRoot, { recursive: true });
122+
123+
await expect(
124+
installSkillFiles("good", [{ path: "../escape.txt", content: "x" }], skillsRoot)
125+
).rejects.toThrow(/outside/);
126+
});
127+
});
128+
129+
describe("symlinkSkill", () => {
130+
test("creates symlink under skills root for safe name", async () => {
131+
const skillsRoot = join(tempDir, "linked");
132+
await mkdir(skillsRoot, { recursive: true });
133+
134+
const source = join(tempDir, "source");
135+
await mkdir(source, { recursive: true });
136+
await writeFile(join(source, "marker"), "ok");
137+
138+
await symlinkSkill("good", source, skillsRoot);
139+
140+
const linkedMarker = await readFile(join(skillsRoot, "good", "marker"), "utf8");
141+
expect(linkedMarker).toBe("ok");
142+
});
143+
144+
test("does not rm parent when skill name is '..'", async () => {
145+
const skillsRoot = join(tempDir, ".cursor", "skills");
146+
await mkdir(skillsRoot, { recursive: true });
147+
148+
const sentinel = join(tempDir, ".cursor", "keep.txt");
149+
await writeFile(sentinel, "sentinel");
150+
151+
const source = join(tempDir, "source");
152+
await mkdir(source, { recursive: true });
153+
154+
await expect(symlinkSkill("..", source, skillsRoot)).rejects.toThrow();
155+
156+
expect(await exists(sentinel)).toBe(true);
157+
expect(await exists(join(tempDir, ".cursor"))).toBe(true);
158+
});
159+
});

packages/cli/src/commands/skill.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
getTrustLabel,
3131
} from "../utils/prompts.js";
3232
import { installSkillFiles, symlinkSkill } from "../utils/installer.js";
33+
import { assertSkillNameInRoot } from "../utils/skill-name.js";
3334
import { listSkillsFromGitHub, getSkillFromGitHub } from "../utils/github.js";
3435
import { trackEvent } from "../utils/tracking.js";
3536
import { registerGenerateCommand } from "./generate.js";
@@ -708,7 +709,13 @@ async function removeCommand(name: string, options: RemoveOptions): Promise<void
708709
}
709710

710711
const skillsDir = getTargetDirFromSelection(target.ide, target.scope);
711-
const skillPath = join(skillsDir, name);
712+
let skillPath: string;
713+
try {
714+
skillPath = assertSkillNameInRoot(skillsDir, name);
715+
} catch {
716+
log.error(`Invalid skill name: ${name}`);
717+
return;
718+
}
712719

713720
try {
714721
await rm(skillPath, { recursive: true });

packages/cli/src/utils/github.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execSync } from "node:child_process";
22
import type { SkillFile, Skill } from "../types.js";
3+
import { isSafeSkillName } from "./skill-name.js";
34

45
const GITHUB_API = "https://api.github.com";
56
const GITHUB_RAW = "https://raw.githubusercontent.com";
@@ -100,10 +101,8 @@ function parseSkillFrontmatter(content: string): { name: string; description: st
100101

101102
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
102103
if (!nameMatch) return null;
103-
const name = nameMatch[1]
104-
.trim()
105-
.replace(/^["']|["']$/g, "")
106-
.toLowerCase();
104+
const name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
105+
if (!isSafeSkillName(name)) return null;
107106

108107
let description = "";
109108
const multiLineMatch = frontmatter.match(/^description:\s*([|>])-?\s*$/m);
@@ -230,7 +229,7 @@ export async function getSkillFromGitHub(
230229
): Promise<GitHubSkillsResult & { skill?: Skill & { project: string } }> {
231230
const result = await listSkillsFromGitHub(project);
232231
if (result.status !== "ok") return result;
233-
const skill = result.skills.find((s) => s.name === skillName.toLowerCase());
232+
const skill = result.skills.find((s) => s.name.toLowerCase() === skillName.toLowerCase());
234233
return { ...result, skill };
235234
}
236235

packages/cli/src/utils/installer.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { mkdir, writeFile, rm, symlink, lstat } from "fs/promises";
2-
import { join, resolve, dirname } from "path";
2+
import { resolve, dirname } from "path";
33

44
import type { SkillFile } from "../types.js";
5+
import { assertSkillNameInRoot } from "./skill-name.js";
56

67
export async function installSkillFiles(
78
skillName: string,
89
files: SkillFile[],
9-
targetDir: string
10+
skillsRoot: string
1011
): Promise<void> {
11-
const skillDir = resolve(targetDir, skillName);
12+
const skillDir = assertSkillNameInRoot(skillsRoot, skillName);
1213

1314
for (const file of files) {
1415
const filePath = resolve(skillDir, file.path);
@@ -32,9 +33,9 @@ export async function installSkillFiles(
3233
export async function symlinkSkill(
3334
skillName: string,
3435
sourcePath: string,
35-
targetDir: string
36+
skillsRoot: string
3637
): Promise<void> {
37-
const targetPath = join(targetDir, skillName);
38+
const targetPath = assertSkillNameInRoot(skillsRoot, skillName);
3839

3940
try {
4041
const stats = await lstat(targetPath);
@@ -43,6 +44,6 @@ export async function symlinkSkill(
4344
}
4445
} catch {}
4546

46-
await mkdir(targetDir, { recursive: true });
47+
await mkdir(skillsRoot, { recursive: true });
4748
await symlink(sourcePath, targetPath);
4849
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { resolve, dirname, basename } from "path";
2+
3+
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
4+
5+
export function isSafeSkillName(name: string): boolean {
6+
if (typeof name !== "string") return false;
7+
if (name.length === 0 || name.length > 128) return false;
8+
if (name === "." || name === "..") return false;
9+
if (name.includes("\0")) return false;
10+
if (!SAFE_NAME.test(name)) return false;
11+
return true;
12+
}
13+
14+
export function assertSkillNameInRoot(skillsRoot: string, skillName: string): string {
15+
if (!isSafeSkillName(skillName)) {
16+
throw new Error(`Unsafe skill name: ${JSON.stringify(skillName)}`);
17+
}
18+
const root = resolve(skillsRoot);
19+
const target = resolve(root, skillName);
20+
if (dirname(target) !== root || basename(target) !== skillName) {
21+
throw new Error(`Skill name "${skillName}" escapes the skills root`);
22+
}
23+
return target;
24+
}

0 commit comments

Comments
 (0)