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.
- package/dist/{chunk-WAFKFCAB.js → chunk-LGQBVN2C.js} +4 -4
- package/dist/index.js +12 -12
- package/dist/{init-KPUEY6NC.js → init-EJXHIZCQ.js} +1 -1
- package/dist/{mcp-V7PM4WTE.js → mcp-4FRVCJBX.js} +1 -1
- package/dist/{memory-36YZTSSE.js → memory-ER5F37WW.js} +1 -1
- package/lib/learn-mcp/package-lock.json +1385 -166
- package/lib/learn-mcp/package.json +5 -2
- package/lib/learn-mcp/src/__tests__/docs.test.ts +117 -0
- package/lib/learn-mcp/src/lib/docs.ts +23 -6
- package/lib/learn-mcp/src/tools/create-doc.ts +21 -9
- package/lib/learn-mcp/vitest.config.ts +8 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
}
|