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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:48:56 +01:00
1 parent cfc41c6a63
commit ead7327bb1
3 files changed
+139 -14

No files matched your search

+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;
}