coding-friend-cli 1.26.0 → 1.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,9 @@
9
9
  "build": "tsc",
10
10
  "dev": "tsx src/index.ts",
11
11
  "dev:watch": "tsc --watch --preserveWatchOutput",
12
- "start": "node dist/index.js"
12
+ "start": "node dist/index.js",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
13
15
  },
14
16
  "dependencies": {
15
17
  "@modelcontextprotocol/sdk": "^1.27.0",
@@ -19,6 +21,7 @@
19
21
  "devDependencies": {
20
22
  "@types/node": "^22.0.0",
21
23
  "tsx": "^4.0.0",
22
- "typescript": "^5.7.0"
24
+ "typescript": "^5.7.0",
25
+ "vitest": "^4.0.0"
23
26
  }
24
27
  }
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import matter from "gray-matter";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { createDoc, readDoc, updateDoc } from "../lib/docs.js";
7
+
8
+ /**
9
+ * Place a valid .md file outside docsDir so traversal attempts
10
+ * actually find a file — this proves the guard blocks the read,
11
+ * not just "file not found".
12
+ */
13
+ function placeTrapFile(dir: string, name: string): string {
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ const filePath = path.join(dir, name);
16
+ const content = matter.stringify("SECRET CONTENT", {
17
+ title: "Trap",
18
+ category: "trap",
19
+ tags: [],
20
+ created: "2026-01-01",
21
+ updated: "2026-01-01",
22
+ });
23
+ fs.writeFileSync(filePath, content, "utf-8");
24
+ return filePath;
25
+ }
26
+
27
+ describe("docs path traversal protection", () => {
28
+ let tmpDir: string;
29
+ let docsDir: string;
30
+
31
+ beforeEach(() => {
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "learn-mcp-test-"));
33
+ docsDir = path.join(tmpDir, "docs");
34
+ fs.mkdirSync(docsDir, { recursive: true });
35
+
36
+ // Create a legitimate doc for testing
37
+ createDoc(docsDir, "javascript", "test-doc", ["test"], "Hello world");
38
+ });
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(tmpDir, { recursive: true, force: true });
42
+ });
43
+
44
+ describe("readDoc", () => {
45
+ it("reads legitimate docs normally", () => {
46
+ const doc = readDoc(docsDir, "javascript", "test-doc");
47
+ expect(doc).not.toBeNull();
48
+ expect(doc!.content).toContain("Hello world");
49
+ });
50
+
51
+ it("rejects path traversal in category", () => {
52
+ // Place a trap file at tmpDir/secret.md (one level above docsDir)
53
+ placeTrapFile(tmpDir, "secret.md");
54
+ const doc = readDoc(docsDir, "..", "secret");
55
+ expect(doc).toBeNull();
56
+ });
57
+
58
+ it("rejects path traversal in slug", () => {
59
+ placeTrapFile(tmpDir, "secret.md");
60
+ const doc = readDoc(docsDir, "javascript", "../../secret");
61
+ expect(doc).toBeNull();
62
+ });
63
+
64
+ it("rejects path traversal with nested sequences", () => {
65
+ placeTrapFile(tmpDir, "secret.md");
66
+ const doc = readDoc(docsDir, "javascript/../../..", "secret");
67
+ expect(doc).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("createDoc", () => {
72
+ it("creates legitimate docs normally", () => {
73
+ const filePath = createDoc(
74
+ docsDir,
75
+ "python",
76
+ "New Doc",
77
+ ["tag1"],
78
+ "Content",
79
+ );
80
+ expect(filePath).toContain(docsDir);
81
+ expect(fs.existsSync(filePath)).toBe(true);
82
+ });
83
+
84
+ it("rejects path traversal in category", () => {
85
+ expect(() =>
86
+ createDoc(docsDir, "../escape", "title", [], "content"),
87
+ ).toThrow();
88
+ // Verify no file was created outside docsDir
89
+ expect(fs.existsSync(path.join(tmpDir, "escape"))).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("updateDoc", () => {
94
+ it("updates legitimate docs normally", () => {
95
+ const result = updateDoc(docsDir, "javascript", "test-doc", {
96
+ title: "Updated",
97
+ });
98
+ expect(result).not.toBeNull();
99
+ });
100
+
101
+ it("rejects path traversal in category", () => {
102
+ placeTrapFile(tmpDir, "secret.md");
103
+ const result = updateDoc(docsDir, "..", "secret", {
104
+ title: "hack",
105
+ });
106
+ expect(result).toBeNull();
107
+ });
108
+
109
+ it("rejects path traversal in slug", () => {
110
+ placeTrapFile(tmpDir, "secret.md");
111
+ const result = updateDoc(docsDir, "javascript", "../../secret", {
112
+ title: "hack",
113
+ });
114
+ expect(result).toBeNull();
115
+ });
116
+ });
117
+ });
@@ -3,6 +3,17 @@ import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import type { CategoryInfo, Doc, DocFrontmatter, DocMeta } from "./types.js";
5
5
 
6
+ function safePath(docsDir: string, ...segments: string[]): string | null {
7
+ const resolved = path.resolve(docsDir, ...segments);
8
+ if (
9
+ !resolved.startsWith(path.resolve(docsDir) + path.sep) &&
10
+ resolved !== path.resolve(docsDir)
11
+ ) {
12
+ return null;
13
+ }
14
+ return resolved;
15
+ }
16
+
6
17
  function isMarkdownDoc(filePath: string): boolean {
7
18
  const name = path.basename(filePath);
8
19
  return name.endsWith(".md") && name !== "README.md";
@@ -92,8 +103,8 @@ export function readDoc(
92
103
  category: string,
93
104
  slug: string,
94
105
  ): Doc | null {
95
- const filePath = path.join(docsDir, category, `${slug}.md`);
96
- if (!fs.existsSync(filePath)) return null;
106
+ const filePath = safePath(docsDir, category, `${slug}.md`);
107
+ if (!filePath || !fs.existsSync(filePath)) return null;
97
108
 
98
109
  const raw = matter(fs.readFileSync(filePath, "utf-8"));
99
110
  const frontmatter = parseFrontmatter(raw);
@@ -147,13 +158,19 @@ export function createDoc(
147
158
  tags: string[],
148
159
  content: string,
149
160
  ): string {
150
- const catDir = path.join(docsDir, category);
161
+ const catDir = safePath(docsDir, category);
162
+ if (!catDir) {
163
+ throw new Error(`Invalid category path: ${category}`);
164
+ }
151
165
  if (!fs.existsSync(catDir)) {
152
166
  fs.mkdirSync(catDir, { recursive: true });
153
167
  }
154
168
 
155
169
  const slug = slugify(title);
156
- const filePath = path.join(catDir, `${slug}.md`);
170
+ const filePath = safePath(docsDir, category, `${slug}.md`);
171
+ if (!filePath) {
172
+ throw new Error(`Invalid doc path: ${category}/${slug}`);
173
+ }
157
174
  const today = new Date().toISOString().split("T")[0];
158
175
 
159
176
  const doc = matter.stringify(content, {
@@ -174,8 +191,8 @@ export function updateDoc(
174
191
  slug: string,
175
192
  updates: { content?: string; tags?: string[]; title?: string },
176
193
  ): string | null {
177
- const filePath = path.join(docsDir, category, `${slug}.md`);
178
- if (!fs.existsSync(filePath)) return null;
194
+ const filePath = safePath(docsDir, category, `${slug}.md`);
195
+ if (!filePath || !fs.existsSync(filePath)) return null;
179
196
 
180
197
  const raw = matter(fs.readFileSync(filePath, "utf-8"));
181
198
  const today = new Date().toISOString().split("T")[0];
@@ -15,15 +15,27 @@ export function registerCreateDoc(server: McpServer, docsDir: string): void {
15
15
  content: z.string().describe("Markdown content (without frontmatter)"),
16
16
  },
17
17
  async ({ category, title, tags, content }) => {
18
- const filePath = createDoc(docsDir, category, title, tags, content);
19
- return {
20
- content: [
21
- {
22
- type: "text",
23
- text: JSON.stringify({ path: filePath, created: true }, null, 2),
24
- },
25
- ],
26
- };
18
+ try {
19
+ const filePath = createDoc(docsDir, category, title, tags, content);
20
+ return {
21
+ content: [
22
+ {
23
+ type: "text",
24
+ text: JSON.stringify({ path: filePath, created: true }, null, 2),
25
+ },
26
+ ],
27
+ };
28
+ } catch (err) {
29
+ return {
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: err instanceof Error ? err.message : String(err),
34
+ },
35
+ ],
36
+ isError: true,
37
+ };
38
+ }
27
39
  },
28
40
  );
29
41
  }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ exclude: ["dist/**", "node_modules/**"],
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-friend-cli",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "CLI for coding-friend — host learning docs, setup MCP server, initialize projects",
5
5
  "type": "module",
6
6
  "bin": {