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

fix: align truth file and book id safety checks

fanghanjun 6 месяцев назад
Родитель
Сommit
3b8075c01c

+ 43 - 0
packages/core/src/__tests__/interaction-tools.test.ts

@@ -184,6 +184,49 @@ describe("interaction tools", () => {
       .resolves.toContain("harbor mystery");
   });
 
+  it("rejects truth-file writes outside the canonical truth-file allowlist", async () => {
+    const tools = createInteractionToolsFromDeps(
+      {
+        writeNextChapter: vi.fn(async () => ({
+          chapterNumber: 1,
+          title: "Draft",
+          wordCount: 1000,
+          revised: false,
+          status: "ready-for-review" as const,
+          auditResult: { passed: true, issues: [], summary: "ok" },
+        })),
+        reviseDraft: vi.fn(async () => ({
+          chapterNumber: 3,
+          wordCount: 1200,
+          fixedIssues: [],
+          applied: true,
+          status: "ready-for-review" as const,
+        })),
+      },
+      {
+        ensureControlDocuments: vi.fn(async () => {}),
+        bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+        loadBookConfig: vi.fn(async () => ({
+          id: "harbor",
+          title: "Harbor",
+          platform: "other" as const,
+          genre: "other",
+          status: "outlining" as const,
+          targetChapters: 200,
+          chapterWordCount: 3000,
+          createdAt: "2026-04-10T00:00:00.000Z",
+          updatedAt: "2026-04-10T00:00:00.000Z",
+        })),
+        loadChapterIndex: vi.fn(async () => []),
+        saveChapterIndex: vi.fn(async () => undefined),
+        listBooks: vi.fn(async () => ["harbor"]),
+      },
+    );
+
+    await expect(tools.writeTruthFile("harbor", "runtime/agent_notes.md", "notes"))
+      .rejects.toThrow("Invalid truth file name");
+  });
+
   it("forwards foundation draft fields into shared book creation", async () => {
     const pipeline = {
       initBook: vi.fn(async () => undefined),

+ 1 - 47
packages/core/src/agent/agent-tools.ts

@@ -5,7 +5,7 @@ import { type ReviseMode } from "../agents/reviser.js";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { isAbsolute, join, resolve } from "node:path";
 import { StateManager } from "../state/manager.js";
-import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+import { assertSafeTruthFileName, createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 import { writeExportArtifact } from "../interaction/export-artifact.js";
 import { assertSafeBookId, deriveBookIdFromTitle } from "../utils/book-id.js";
 import { safeChildPath } from "../utils/path-safety.js";
@@ -256,52 +256,6 @@ const WriteTruthFileParams = Type.Object({
   content: Type.String({ description: "Full replacement content for the truth file." }),
 });
 
-const SAFE_TRUTH_FLAT_FILE_NAMES = new Set([
-  "author_intent.md",
-  "current_focus.md",
-  "story_bible.md",
-  "volume_outline.md",
-  "book_rules.md",
-  "particle_ledger.md",
-  "subplot_board.md",
-  "emotional_arcs.md",
-  "style_guide.md",
-  "parent_canon.md",
-  "fanfic_canon.md",
-  "character_matrix.md",
-  "current_state.md",
-  "pending_hooks.md",
-  "chapter_summaries.md",
-]);
-
-const SAFE_TRUTH_OUTLINE_FILE_NAMES = new Set([
-  "outline/story_frame.md",
-  "outline/volume_map.md",
-  "outline/节奏原则.md",
-  "outline/rhythm_principles.md",
-]);
-
-const SAFE_ROLE_TRUTH_FILE_RE = /^roles\/(主要角色|次要角色|major|minor)\/[^/\\]+\.md$/u;
-
-function assertSafeTruthFileName(fileName: string): string {
-  const trimmed = fileName.trim();
-  const withExtension = trimmed.endsWith(".md") ? trimmed : `${trimmed}.md`;
-  const lower = withExtension.toLowerCase();
-  if (
-    !trimmed ||
-    withExtension.startsWith("/") ||
-    withExtension.includes("\\") ||
-    withExtension.includes("\0") ||
-    withExtension.includes("..")
-  ) {
-    throw new Error(`Invalid truth file name: ${JSON.stringify(fileName)}`);
-  }
-  if (SAFE_TRUTH_FLAT_FILE_NAMES.has(lower)) return lower;
-  if (SAFE_TRUTH_OUTLINE_FILE_NAMES.has(lower)) return lower;
-  if (SAFE_ROLE_TRUTH_FILE_RE.test(withExtension)) return withExtension;
-  throw new Error(`Invalid truth file name: ${JSON.stringify(fileName)}`);
-}
-
 export function createWriteTruthFileTool(
   pipeline: PipelineRunner,
   projectRoot: string,

+ 48 - 1
packages/core/src/interaction/project-tools.ts

@@ -18,6 +18,52 @@ import type { BookCreationDraft } from "./session.js";
 import { writeExportArtifact } from "./export-artifact.js";
 import { safeChildPath } from "../utils/path-safety.js";
 
+const SAFE_TRUTH_FLAT_FILE_NAMES = new Set([
+  "author_intent.md",
+  "current_focus.md",
+  "story_bible.md",
+  "volume_outline.md",
+  "book_rules.md",
+  "particle_ledger.md",
+  "subplot_board.md",
+  "emotional_arcs.md",
+  "style_guide.md",
+  "parent_canon.md",
+  "fanfic_canon.md",
+  "character_matrix.md",
+  "current_state.md",
+  "pending_hooks.md",
+  "chapter_summaries.md",
+]);
+
+const SAFE_TRUTH_OUTLINE_FILE_NAMES = new Set([
+  "outline/story_frame.md",
+  "outline/volume_map.md",
+  "outline/节奏原则.md",
+  "outline/rhythm_principles.md",
+]);
+
+const SAFE_ROLE_TRUTH_FILE_RE = /^roles\/(主要角色|次要角色|major|minor)\/[^/\\]+\.md$/u;
+
+export function assertSafeTruthFileName(fileName: string): string {
+  const trimmed = fileName.trim();
+  const withExtension = trimmed.endsWith(".md") ? trimmed : `${trimmed}.md`;
+  const lower = withExtension.toLowerCase();
+  if (
+    !trimmed ||
+    withExtension.startsWith("/") ||
+    withExtension.includes("\\") ||
+    withExtension.includes("\0") ||
+    withExtension.includes("..")
+  ) {
+    throw new Error(`Invalid truth file name: ${JSON.stringify(fileName)}`);
+  }
+  if (SAFE_TRUTH_FLAT_FILE_NAMES.has(lower)) return lower;
+  if (SAFE_TRUTH_OUTLINE_FILE_NAMES.has(lower)) return lower;
+  if (SAFE_ROLE_TRUTH_FILE_RE.test(withExtension)) return withExtension;
+  throw new Error(`Invalid truth file name: ${JSON.stringify(fileName)}`);
+}
+
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
   readonly initBook?: (
     book: BookConfig,
@@ -667,7 +713,8 @@ export function createInteractionToolsFromDeps(
     writeTruthFile: async (bookId, fileName, content) => {
       await state.ensureControlDocuments(bookId);
       const storyDir = join(state.bookDir(bookId), "story");
-      const targetPath = safeChildPath(storyDir, fileName);
+      const safeFileName = assertSafeTruthFileName(fileName);
+      const targetPath = safeChildPath(storyDir, safeFileName);
       await mkdir(dirname(targetPath), { recursive: true });
       await writeFile(targetPath, content, "utf-8");
     },

+ 4 - 11
packages/studio/src/api/phase5-hotfix.test.ts

@@ -19,7 +19,9 @@ const logger = {
   error: vi.fn(),
 };
 
-vi.mock("@actalk/inkos-core", () => {
+vi.mock("@actalk/inkos-core", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@actalk/inkos-core")>();
+
   class MockStateManager {
     constructor(private readonly root: string) {}
     async listBooks(): Promise<string[]> {
@@ -72,16 +74,7 @@ vi.mock("@actalk/inkos-core", () => {
     createLLMClient: createLLMClientMock,
     createLogger: vi.fn(() => logger),
     computeAnalytics: vi.fn(() => ({})),
-    isSafeBookId: vi.fn((bookId: unknown) => (
-      typeof bookId === "string"
-      && bookId.length > 0
-      && bookId.length <= 120
-      && bookId.trim() === bookId
-      && bookId !== "."
-      && bookId !== ".."
-      && !bookId.includes("..")
-      && !/[\u0000-\u001f\u007f/\\:*?"'`{}<>|]/u.test(bookId)
-    )),
+    isSafeBookId: actual.isSafeBookId,
     chatCompletion: chatCompletionMock,
     loadProjectConfig: loadProjectConfigMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),

+ 12 - 11
packages/studio/src/api/server.test.ts

@@ -116,7 +116,9 @@ const logger = {
   error: vi.fn(),
 };
 
-vi.mock("@actalk/inkos-core", () => {
+vi.mock("@actalk/inkos-core", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@actalk/inkos-core")>();
+
   class MockSessionAlreadyMigratedError extends Error {
     constructor(message = "Session already migrated") {
       super(message);
@@ -198,16 +200,7 @@ vi.mock("@actalk/inkos-core", () => {
     createLLMClient: createLLMClientMock,
     createLogger: vi.fn(() => logger),
     computeAnalytics: vi.fn(() => ({})),
-    isSafeBookId: vi.fn((bookId: unknown) => (
-      typeof bookId === "string"
-      && bookId.length > 0
-      && bookId.length <= 120
-      && bookId.trim() === bookId
-      && bookId !== "."
-      && bookId !== ".."
-      && !bookId.includes("..")
-      && !/[\u0000-\u001f\u007f/\\:*?"'`{}<>|]/u.test(bookId)
-    )),
+    isSafeBookId: actual.isSafeBookId,
     chatCompletion: chatCompletionMock,
     loadProjectConfig: loadProjectConfigMock,
     processProjectInteractionInput: processProjectInteractionInputMock,
@@ -444,6 +437,14 @@ describe("createStudioServer daemon lifecycle", () => {
     await rm(join(tmpdir(), "inkos-global.env"), { force: true });
   });
 
+  it("uses the real core bookId validator in the Studio safety mock", async () => {
+    const { isSafeBookId } = await import("@actalk/inkos-core");
+
+    expect(vi.isMockFunction(isSafeBookId)).toBe(false);
+    expect(isSafeBookId("demo-book")).toBe(true);
+    expect(isSafeBookId("demo/book")).toBe(false);
+  });
+
   it("returns from /api/daemon/start before the first write cycle finishes", async () => {
     let resolveStart: (() => void) | undefined;
     schedulerStartMock.mockImplementation(

+ 4 - 11
packages/studio/src/api/v13-hotfix-round4.test.ts

@@ -24,7 +24,9 @@ const logger = {
   error: vi.fn(),
 };
 
-vi.mock("@actalk/inkos-core", () => {
+vi.mock("@actalk/inkos-core", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@actalk/inkos-core")>();
+
   class MockStateManager {
     constructor(private readonly root: string) {}
     async listBooks(): Promise<string[]> { return []; }
@@ -65,16 +67,7 @@ vi.mock("@actalk/inkos-core", () => {
     createLLMClient: vi.fn(() => ({})),
     createLogger: vi.fn(() => logger),
     computeAnalytics: vi.fn(() => ({})),
-    isSafeBookId: vi.fn((bookId: unknown) => (
-      typeof bookId === "string"
-      && bookId.length > 0
-      && bookId.length <= 120
-      && bookId.trim() === bookId
-      && bookId !== "."
-      && bookId !== ".."
-      && !bookId.includes("..")
-      && !/[\u0000-\u001f\u007f/\\:*?"'`{}<>|]/u.test(bookId)
-    )),
+    isSafeBookId: actual.isSafeBookId,
     chatCompletion: vi.fn(),
     loadProjectConfig: loadProjectConfigMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),