fix(ai): solution and hidden-file guard runs on the canonical real path

read_file and read_lesson URL-decode, posix-normalise, reject .. and absolute
paths, resolve through CourseRepository.resolvePath (symlinks followed), then
guard the real course-relative path and compare real paths with every
manifest browser.solution. Hidden files are refused too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:48:56 +01:00
co-authored by Claude Opus 5.5
parent cfc41c6a63
commit ead7327bb1
3 changed files with 139 additions and 14 deletions
+11 -9
View File
@@ -3,7 +3,7 @@ import type { ToolSpec } from "@stc/contracts";
import type { CourseRepository, LoadedCourse } from "@stc/course-format";
import type { McpServerConfig } from "../settings";
import { connectMcp, mcpToolName, type McpClient } from "../mcp/client";
import { isSolutionPath, noteChunks, type Chunk, type CourseIndex } from "../retrieval/course-index";
import { canonicalCoursePath, isGuardedFile, noteChunks, type Chunk, type CourseIndex } from "../retrieval/course-index";
export interface ToolSet {
specs: ToolSpec[];
@@ -86,22 +86,24 @@ export function courseTools(o: {
const id = str(a.lessonId);
const ref = lessonChunk(id);
if (!ref) return `No lesson with id "${id}". Lessons:\n${lessonList()}`;
if (isSolutionPath(course, ref.path)) return "That file is for authors only.";
const canon = ref.path ? canonicalCoursePath(o.repo, course, ref.path) : null;
if (canon && isGuardedFile(o.repo, course, canon)) return "That file is for authors only.";
const md = await o.repo.readLessonMarkdown(course.id, id);
if (md === null) return `Lesson "${id}" has no readable text (it may be a video without a transcript).`;
sources.push(ref);
return md.slice(0, MAX_READ);
}
case "read_file": {
const rel = str(a.path).replace(/^\/+/, "");
if (isSolutionPath(course, rel)) return "That file is for authors only and is not available.";
const abs = o.repo.resolvePath(course.id, rel);
const info = abs ? await stat(abs).catch(() => null) : null;
if (!abs || !info?.isFile()) return `No such file in this course: ${rel}`;
// Canonicalise first (decode, normalise, follow symlinks), then guard the real path.
const canon = canonicalCoursePath(o.repo, course, str(a.path));
if (!canon) return `No such file in this course: ${str(a.path)}`;
if (isGuardedFile(o.repo, course, canon)) return "That file is for authors only and is not available.";
const info = await stat(canon.abs).catch(() => null);
if (!info?.isFile()) return `No such file in this course: ${canon.rel}`;
if (info.size > 2_000_000) return "That file is too large to read here.";
const text = await Bun.file(abs).text();
const text = await Bun.file(canon.abs).text();
if (text.includes("\u0000")) return "That is a binary file.";
sources.push({ path: rel, kind: /\.(md|markdown)$/i.test(rel) ? "markdown" : "code", text: "" });
sources.push({ path: canon.rel, kind: /\.(md|markdown)$/i.test(canon.rel) ? "markdown" : "code", text: "" });
return text.slice(0, MAX_READ);
}
case "list_notes": {
@@ -1,6 +1,7 @@
import { realpathSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { basename, extname, join, relative, sep } from "node:path";
import { slugify, type LoadedCourse } from "@stc/course-format";
import { basename, extname, join, posix as posixPath, relative, sep } from "node:path";
import { slugify, type CourseRepository, type LoadedCourse } from "@stc/course-format";
import { Bm25 } from "./bm25";
export type ChunkKind = "lesson" | "transcript" | "markdown" | "code" | "deck" | "note";
@@ -32,13 +33,68 @@ const posix = (p: string) => p.split(sep).join("/");
/**
* Files only authors may see: manifest `browser.solution` files and anything under a `solution`, `solutions`
* or `reference` folder (Pure Snake `js/solutions/`, Systemic Design `reference/` and `expansion/solution/`).
* The assistant never retrieves or reads them.
* The assistant never retrieves or reads them. `rel` is normalised first; for paths that come from a user or a
* model, use `canonicalCoursePath` + `isGuardedFile`, which also follow symlinks.
*/
export function isSolutionPath(course: LoadedCourse, rel: string): boolean {
const p = posix(rel).replace(/^\.\//, "");
const p = normalizeRel(rel);
if (p === null) return true; // not a course-relative path at all: refuse
if (p.split("/").slice(0, -1).some((s) => SOLUTION_SEGMENTS.has(s.toLowerCase()))) return true;
if (/(^|\/)solution\.[a-z]+$/i.test(p)) return true;
for (const m of course.manifest?.modules ?? []) for (const l of m.lessons) if (l.browser?.solution && posix(l.browser.solution) === p) return true;
for (const m of course.manifest?.modules ?? []) for (const l of m.lessons) if (l.browser?.solution && normalizeRel(l.browser.solution) === p) return true;
return false;
}
/** Posix-normalised course-relative path, or null when it is absolute or climbs out with `..`. */
export function normalizeRel(raw: string): string | null {
if (raw.includes("\u0000")) return null;
const p = posixPath.normalize(raw.replace(/\\/g, "/")).replace(/^(\.\/)+/, "");
if (!p || p === "." || p.startsWith("/") || /^[a-zA-Z]:/.test(p)) return null;
if (p.split("/").includes("..")) return null;
return p.replace(/\/+$/, "");
}
/** Undoes URL encoding (up to three layers). A malformed escape leaves the string as it is. */
function decodeLayers(raw: string): string {
let s = raw;
for (let i = 0; i < 3 && /%[0-9a-fA-F]{2}/.test(s); i++) {
try { s = decodeURIComponent(s); } catch { break; }
}
return s;
}
/**
* Canonical form of a path a user or the model asked for: URL-decoded, backslashes as slashes, posix-normalised,
* leading `./` and `/` stripped, `..` rejected, then resolved through `CourseRepository.resolvePath` (real path,
* symlinks followed, must stay inside the course root). `rel` is the real path relative to the real root.
* Null when the path is invalid, escapes the course or does not exist.
*/
export function canonicalCoursePath(repo: CourseRepository, course: LoadedCourse, raw: string): { rel: string; abs: string } | null {
const norm = normalizeRel(decodeLayers(raw.trim()).replace(/\\/g, "/").replace(/^\/+/, ""));
if (!norm) return null;
const abs = repo.resolvePath(course.id, norm);
if (!abs) return null;
let realRoot: string;
try { realRoot = realpathSync(course.root); } catch { return null; }
const rel = relative(realRoot, abs).split(sep).join("/");
if (!rel || rel.startsWith("..")) return null;
return { rel, abs };
}
/**
* The guard for tool reads, on canonical paths only: solution files (by the folder and name rules on the real
* relative path, and by comparing real paths with every manifest `browser.solution`) and hidden files or folders.
*/
export function isGuardedFile(repo: CourseRepository, course: LoadedCourse, canonical: { rel: string; abs: string }): boolean {
if (canonical.rel.split("/").some((s) => s.startsWith("."))) return true;
if (isSolutionPath(course, canonical.rel)) return true;
for (const m of course.manifest?.modules ?? []) {
for (const l of m.lessons) {
if (!l.browser?.solution) continue;
const sol = repo.resolvePath(course.id, l.browser.solution);
if (sol && sol === canonical.abs) return true;
}
}
return false;
}
@@ -102,3 +102,70 @@ test("my_progress reports the learner's progress per lesson", async () => {
expect(out).toContain("Adding numbers (m2-text): not started");
expect(out).toMatch(/1 of \d+ lessons done/);
});
// The solution guard must hold for every spelling of a path: it checks the canonical real path.
test("read_file refuses solutions however the path is written, including through symlinks", async () => {
const { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "stc-ai-guard-"));
try {
const root = join(dir, "courses/sample");
cpSync(join(FIXTURES, "sample"), root, { recursive: true });
const yaml = join(root, "stcourse.yaml");
writeFileSync(yaml, readFileSync(yaml, "utf8").replace("solution: exercises/add/solution.js", "solution: exercises/add/answer.js"));
writeFileSync(join(root, "exercises/add/answer.js"), "export const add = (a, b) => a + b; // ANSWER\n");
mkdirSync(join(root, "solutions"));
writeFileSync(join(root, "solutions/ref.js"), "// REFERENCE SOLUTION\n");
writeFileSync(join(root, ".env"), "TOKEN=hidden\n");
symlinkSync("exercises/add/answer.js", join(root, "peek.js"));
symlinkSync("solutions", join(root, "material"));
symlinkSync("../solutions/ref.js", join(root, "lessons/extra.js"));
const r = await createCourseRepository([join(dir, "courses")]);
const c = r.get("sample")!;
expect(c.status).toBe("ok");
const t = courseTools({ repo: r, course: c, index: await buildCourseIndex(c, "/nonexistent"), listNotes: async () => [] });
const read = (path: string) => t.run("read_file", { path });
const refused = [
"exercises/add/answer.js",
"exercises/add/../add/answer.js",
"./exercises/add/answer.js",
"exercises//add/answer.js",
"./exercises/./add/answer.js",
"/exercises/add/answer.js",
"exercises\\add\\answer.js",
"exercises%2Fadd%2Fanswer.js",
"exercises/add/%2E%2E/add/answer.js",
"exercises%252Fadd%252Fanswer.js",
"%2E/exercises/add/answer.js",
"peek.js",
"material/ref.js",
"lessons/extra.js",
"lessons/../solutions/ref.js",
"exercises/add/solution.js",
".env",
"lessons/../.env",
];
for (const p of refused) {
const out = await read(p);
expect({ p, out }).toEqual({ p, out: expect.stringMatching(/authors only|not available|No such file/) });
expect(out).not.toContain("ANSWER");
expect(out).not.toContain("REFERENCE");
expect(out).not.toContain("hidden");
}
expect(await read("exercises/add/answer.js")).toMatch(/authors only/);
expect(await read("peek.js")).toMatch(/authors only/);
expect(await read("../../etc/passwd")).toMatch(/No such file/);
// Ordinary files still read, in any spelling.
expect(await read("exercises/add/starter.js")).toContain("add");
expect(await read("./exercises/./add/../add/starter.js")).toContain("add");
expect(await read("exercises%2Fadd%2Fstarter.js")).toContain("add");
expect(t.sources.at(-1)?.path).toBe("exercises/add/starter.js");
// The index never holds them either.
const idx = await buildCourseIndex(c, "/nonexistent");
const hits = idx.search("ANSWER REFERENCE SOLUTION add", 50);
expect(hits.filter((x) => /answer|ref\.js|peek|material|extra/.test(x.path) || x.text.includes("ANSWER") || x.text.includes("REFERENCE"))).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});