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:
1 parent
cfc41c6a63
commit
ead7327bb1
3 files changed
+139
-14
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user