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

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