Просмотр исходного кода

fix(v13): studio and write_truth_file read/write authoritative Phase 5 paths [phase 5 hotfix 1]

Studio's TRUTH_FILES flat list couldn't reach outline/story_frame.md,
outline/volume_map.md, outline/节奏原则.md or any roles/**/*.md file. New
books therefore surfaced only the pointer shims story_bible.md and
book_rules.md — users editing them believed they were editing the book's
bible/rules, but the runtime reads from outline/story_frame.md, so their
edits silently no-opped.

- Replace the Studio whitelist with a three-tier allowlist (flat files +
  outline/* + roles/{主要角色,次要角色}/*.md) gated by a resolveTruthFilePath
  helper that rejects traversal (`..`, absolute paths, null bytes) and
  verifies the resolved path stays inside bookDir/story/.
- Route pattern switched to `:file{.+}` so nested paths match.
- GET still serves legacy shims (story_bible.md / book_rules.md) so
  pre-cleanup books keep rendering, but tags them with `legacy: true`.
- PUT refuses to write legacy shims — they are runtime-read-only pointers.
- `/api/books/:id/truth` file browser now scans outline/ and roles/ too,
  annotating shims with `legacy: true`.
- write_truth_file whitelist drops story_bible.md and book_rules.md and
  adds roles/{主要角色,次要角色}/*.md. Path-traversal assertions added at
  the write site as a belt-and-braces check.
Ma 6 месяцев назад
Родитель
Сommit
1ebab21c45
2 измененных файлов с 168 добавлено и 34 удалено
  1. 38 11
      packages/core/src/pipeline/agent.ts
  2. 130 23
      packages/studio/src/api/server.ts

+ 38 - 11
packages/core/src/pipeline/agent.ts

@@ -205,12 +205,12 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
   },
   {
     name: "write_truth_file",
-    description: "【整文件覆盖】直接替换书的真相文件内容。用于扩展大纲、修改世界观、调整规则。注意:这是整文件覆盖写入,不是追加;不要用来改 current_state.md 的章节进度指针或 hack 章节号;不要用来补空章节。",
+    description: "【整文件覆盖】直接替换书的真相文件内容。用于扩展大纲、修改世界观、调整规则。注意:这是整文件覆盖写入,不是追加;不要用来改 current_state.md 的章节进度指针或 hack 章节号;不要用来补空章节。book_rules.md / story_bible.md 是 Phase 5 之后的兼容指针,不再作为写入目标——请改写 outline/story_frame.md 的 YAML frontmatter。",
     parameters: {
       type: "object",
       properties: {
         bookId: { type: "string", description: "书籍ID" },
-        fileName: { type: "string", description: "文件名(如 outline/volume_map.md、outline/story_frame.md、book_rules.md、current_state.md、pending_hooks.md;story_bible.md 为兼容指针,也允许写入)" },
+        fileName: { type: "string", description: "文件名(如 outline/story_frame.md、outline/volume_map.md、outline/节奏原则.md、roles/主要角色/<name>.md、roles/次要角色/<name>.md、current_state.md、pending_hooks.md)" },
         content: { type: "string", description: "新的完整文件内容" },
       },
       required: ["bookId", "fileName", "content"],
@@ -563,19 +563,45 @@ export async function executeAgentTool(
       const fileName = args.fileName as string;
       const content = args.content as string;
 
-      // Whitelist allowed truth files
-      const ALLOWED_FILES = [
-        "story_bible.md", "book_rules.md",
+      // Whitelist allowed truth files.
+      //
+      // Phase 5 hotfix 1: book_rules.md and story_bible.md are REMOVED — they
+      // are pointer shims, not authoritative; writes to them are a silent
+      // no-op to the runtime (which reads outline/story_frame.md). The agent
+      // must instead edit outline/story_frame.md's YAML frontmatter + prose.
+      const ALLOWED_FLAT_FILES = [
         "current_state.md", "particle_ledger.md", "pending_hooks.md",
         "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md",
         "character_matrix.md", "style_guide.md",
-        // Phase 5 — new prose outline files
+      ];
+      const ALLOWED_OUTLINE_FILES = [
         "outline/story_frame.md", "outline/volume_map.md",
         "outline/节奏原则.md", "outline/rhythm_principles.md",
       ];
+      const ROLE_PATH_PATTERN = /^roles\/(主要角色|次要角色)\/[^/]+\.md$/;
+
+      const isAllowed =
+        ALLOWED_FLAT_FILES.includes(fileName)
+        || ALLOWED_OUTLINE_FILES.includes(fileName)
+        || ROLE_PATH_PATTERN.test(fileName);
+
+      if (!isAllowed) {
+        const allowedExamples = [
+          ...ALLOWED_FLAT_FILES,
+          ...ALLOWED_OUTLINE_FILES,
+          "roles/主要角色/<name>.md",
+          "roles/次要角色/<name>.md",
+        ];
+        return JSON.stringify({
+          error:
+            `不允许修改文件 "${fileName}"。book_rules.md 和 story_bible.md 是兼容指针,请改写 outline/story_frame.md。允许的文件:${allowedExamples.join(", ")}`,
+        });
+      }
 
-      if (!ALLOWED_FILES.includes(fileName)) {
-        return JSON.stringify({ error: `不允许修改文件 "${fileName}"。允许的文件:${ALLOWED_FILES.join(", ")}` });
+      // Path traversal guard — the whitelist already forbids `..`, but we
+      // re-assert at the write site so this cannot regress.
+      if (fileName.includes("..") || fileName.startsWith("/") || fileName.includes("\0")) {
+        return JSON.stringify({ error: `不安全的文件路径:"${fileName}"` });
       }
 
       // Guard: block chapter progress manipulation via current_state.md
@@ -584,11 +610,12 @@ export async function executeAgentTool(
       }
 
       const { writeFile, mkdir } = await import("node:fs/promises");
-      const { join } = await import("node:path");
+      const { join, dirname } = await import("node:path");
       const bookDir = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
       const storyDir = join(bookDir, "story");
-      await mkdir(storyDir, { recursive: true });
-      await writeFile(join(storyDir, fileName), content, "utf-8");
+      const targetPath = join(storyDir, fileName);
+      await mkdir(dirname(targetPath), { recursive: true });
+      await writeFile(targetPath, content, "utf-8");
 
       return JSON.stringify({
         bookId,

+ 130 - 23
packages/studio/src/api/server.ts

@@ -248,27 +248,97 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   // --- Truth files ---
 
-  const TRUTH_FILES = [
-    "story_bible.md", "current_state.md",
+  // Flat-file whitelist — the pre-Phase-5 story root files.
+  //
+  // Phase 5 cleanup #3 moved the authoritative YAML frontmatter + outline prose
+  // into story/outline/ and character sheets into story/roles/. `story_bible.md`
+  // and `book_rules.md` now exist only as compat pointer shims — we still allow
+  // reading them so legacy books keep rendering, but the server-side writer
+  // (write_truth_file) no longer accepts them as edit targets.
+  const TRUTH_FLAT_FILES = [
+    "story_bible.md", "book_rules.md", "current_state.md",
     "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
     "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
-    "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
+    "style_guide.md", "parent_canon.md", "fanfic_canon.md",
   ];
 
-  app.get("/api/books/:id/truth/:file", async (c) => {
-    const id = c.req.param("id");
+  // Authoritative Phase 5 paths — prose outline + role sheets live under
+  // dedicated subdirectories of story/. The full path (relative to story/) is
+  // matched literally here.
+  const TRUTH_OUTLINE_FILES = [
+    "outline/story_frame.md",
+    "outline/volume_map.md",
+    "outline/节奏原则.md",
+    "outline/rhythm_principles.md",
+  ];
+
+  // Pointer shims that the runtime no longer treats as authoritative. The
+  // GET handler tags them with `legacy: true` so the UI can surface that the
+  // edits won't land where the user expects.
+  const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
+
+  /**
+   * Validate a requested truth-file path:
+   *   1. Must be one of the declared flat files, an outline/* allow-listed
+   *      entry, or a roles/**\/*.md file under 主要角色/ | 次要角色/.
+   *   2. Must resolve to a path inside bookDir/story/ (no `..`, no absolute
+   *      paths, no traversal via the tier-name segment).
+   */
+  function resolveTruthFilePath(bookDir: string, file: string): string | null {
+    // Reject absolute paths, traversal, null bytes outright.
+    if (!file || file.includes("\0") || file.startsWith("/") || file.includes("..")) {
+      return null;
+    }
+
+    const allowed =
+      TRUTH_FLAT_FILES.includes(file)
+      || TRUTH_OUTLINE_FILES.includes(file)
+      || /^roles\/(主要角色|次要角色)\/[^/]+\.md$/.test(file);
+
+    if (!allowed) return null;
+
+    const storyDir = join(bookDir, "story");
+    const resolved = join(storyDir, file);
+    // Path safety: resolved absolute path must sit inside storyDir. `join`
+    // collapses any `..` segments, so compare against the storyDir prefix.
+    const storyPrefix = storyDir.endsWith("/") ? storyDir : storyDir + "/";
+    if (!resolved.startsWith(storyPrefix) && resolved !== storyDir) {
+      return null;
+    }
+    return resolved;
+  }
+
+  async function fileExists(path: string): Promise<boolean> {
+    try {
+      await access(path);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  // Use `:file{.+}` wildcard so nested paths (outline/..., roles/.../...) match.
+  app.get("/api/books/:id/truth/:file{.+}", async (c) => {
     const file = c.req.param("file");
+    const id = c.req.param("id");
 
-    if (!TRUTH_FILES.includes(file)) {
+    const bookDir = state.bookDir(id);
+    const resolved = resolveTruthFilePath(bookDir, file);
+    if (!resolved) {
       return c.json({ error: "Invalid truth file" }, 400);
     }
 
-    const bookDir = state.bookDir(id);
+    // Phase 5: new-layout books keep the authoritative prose under outline/.
+    // A legacy book may only have story_bible.md / book_rules.md on disk —
+    // we still serve those for read-only display, but flag them so the UI
+    // can warn users their edits won't reach the runtime.
+    const legacy = LEGACY_SHIM_FILES.has(file);
+
     try {
-      const content = await readFile(join(bookDir, "story", file), "utf-8");
-      return c.json({ file, content });
+      const content = await readFile(resolved, "utf-8");
+      return c.json({ file, content, ...(legacy ? { legacy: true } : {}) });
     } catch {
-      return c.json({ file, content: null });
+      return c.json({ file, content: null, ...(legacy ? { legacy: true } : {}) });
     }
   });
 
@@ -437,15 +507,41 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const id = c.req.param("id");
     const bookDir = state.bookDir(id);
     const storyDir = join(bookDir, "story");
+
+    async function listDir(subdir: string): Promise<string[]> {
+      try {
+        const entries = await readdir(join(storyDir, subdir));
+        return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
+      } catch {
+        return [];
+      }
+    }
+
+    async function describe(relPath: string): Promise<{ readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } | null> {
+      try {
+        const content = await readFile(join(storyDir, relPath), "utf-8");
+        const entry: { readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } =
+          LEGACY_SHIM_FILES.has(relPath)
+            ? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
+            : { name: relPath, size: content.length, preview: content.slice(0, 200) };
+        return entry;
+      } catch {
+        return null;
+      }
+    }
+
     try {
-      const files = await readdir(storyDir);
-      const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
-      const result = await Promise.all(
-        mdFiles.map(async (f) => {
-          const content = await readFile(join(storyDir, f), "utf-8");
-          return { name: f, size: content.length, preview: content.slice(0, 200) };
-        }),
-      );
+      // Flat story/ files (legacy + runtime logs)
+      const flatFiles = (await listDir(".")).filter((f) => !f.startsWith("outline") && !f.startsWith("roles"));
+      // Phase 5 outline/ files
+      const outlineFiles = (await listDir("outline")).map((f) => `outline/${f}`);
+      // Phase 5 roles/主要角色 + roles/次要角色
+      const majorRoles = (await listDir("roles/主要角色")).map((f) => `roles/主要角色/${f}`);
+      const minorRoles = (await listDir("roles/次要角色")).map((f) => `roles/次要角色/${f}`);
+
+      const all = [...flatFiles, ...outlineFiles, ...majorRoles, ...minorRoles];
+      const described = await Promise.all(all.map(describe));
+      const result = described.filter((x): x is NonNullable<typeof x> => x !== null);
       return c.json({ files: result });
     } catch {
       return c.json({ files: [] });
@@ -866,17 +962,28 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   // --- Truth file edit ---
 
-  app.put("/api/books/:id/truth/:file", async (c) => {
+  app.put("/api/books/:id/truth/:file{.+}", async (c) => {
     const id = c.req.param("id");
     const file = c.req.param("file");
-    if (!TRUTH_FILES.includes(file)) {
+    const bookDir = state.bookDir(id);
+    const resolved = resolveTruthFilePath(bookDir, file);
+    if (!resolved) {
       return c.json({ error: "Invalid truth file" }, 400);
     }
+    // Legacy pointer shims are read-only: writing story_bible.md or
+    // book_rules.md does nothing at runtime (the pipeline reads outline/
+    // instead), so refuse to accept edits that would silently no-op.
+    if (LEGACY_SHIM_FILES.has(file)) {
+      return c.json(
+        { error: "Legacy compat shim; edit outline/story_frame.md instead" },
+        400,
+      );
+    }
     const { content } = await c.req.json<{ content: string }>();
-    const bookDir = state.bookDir(id);
     const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
-    await mkdirFs(join(bookDir, "story"), { recursive: true });
-    await writeFileFs(join(bookDir, "story", file), content, "utf-8");
+    const { dirname: dirnameFs } = await import("node:path");
+    await mkdirFs(dirnameFs(resolved), { recursive: true });
+    await writeFileFs(resolved, content, "utf-8");
     return c.json({ ok: true });
   });