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

fix(agent): allow session read tool system paths

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

+ 49 - 0
packages/core/src/__tests__/agent-session.test.ts

@@ -219,6 +219,55 @@ describe("runAgentSession cache — bookId switch", () => {
     expect(agentInstances).toHaveLength(1);
   });
 
+  it("enables system file read by default for the session read tool", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    const outsidePath = join(projectRoot, "outside.md");
+    await writeFile(outsidePath, "outside content", "utf-8");
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+
+    const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
+    const result = await readTool.execute("tool-read-default-session", { path: outsidePath });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("outside content");
+    }
+  });
+
+  it("can explicitly disable system file read for the session read tool", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    const outsidePath = join(projectRoot, "outside.md");
+    await writeFile(outsidePath, "outside content", "utf-8");
+
+    await runAgentSession(
+      {
+        sessionId: "s1",
+        bookId: null,
+        language: "zh",
+        pipeline,
+        projectRoot,
+        model,
+        allowSystemFileRead: false,
+      },
+      "hi",
+    );
+
+    const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
+    const result = await readTool.execute("tool-read-disabled-session", { path: outsidePath });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("Path traversal blocked");
+      expect(result.content[0].text).not.toContain("outside content");
+    }
+  });
+
   it("把真实 Agent 的 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
     const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
     const pipeline = {} as any;

+ 32 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { StateManager } from "../state/manager.js";
 import {
+  createReadTool,
   createSubAgentTool,
   createPatchChapterTextTool,
   createRenameEntityTool,
@@ -200,6 +201,37 @@ describe("agent deterministic writing tools", () => {
     }
   });
 
+  it("keeps read tool scoped to books by default", async () => {
+    const outsidePath = join(root, "outside.md");
+    await writeFile(outsidePath, "outside secret", "utf-8");
+    const tool = createReadTool(root);
+
+    const result = await tool.execute("tool-read-default", {
+      path: outsidePath,
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("Path traversal blocked");
+      expect(result.content[0].text).not.toContain("outside secret");
+    }
+  });
+
+  it("reads absolute system paths when explicitly enabled", async () => {
+    const outsidePath = join(root, "outside.md");
+    await writeFile(outsidePath, "outside secret", "utf-8");
+    const tool = createReadTool(root, { allowSystemPaths: true });
+
+    const result = await tool.execute("tool-read-system", {
+      path: outsidePath,
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("outside secret");
+    }
+  });
+
   it("creates nested files through the generic write tool", async () => {
     const tool = createWriteFileTool(root);
 

+ 15 - 2
packages/core/src/agent/agent-session.ts

@@ -44,6 +44,8 @@ export interface AgentSessionConfig {
   model: Model<Api> | { provider: string; modelId: string };
   /** Optional API key. When omitted, falls back to env-based key lookup. */
   apiKey?: string;
+  /** Allow the read tool to read absolute paths outside projectRoot/books. Defaults to true; set INKOS_AGENT_ALLOW_SYSTEM_READ=0 to disable. */
+  allowSystemFileRead?: boolean;
   /** Optional listener for streaming events (for SSE forwarding). */
   onEvent?: (event: AgentEvent) => void;
 }
@@ -68,6 +70,7 @@ interface CachedAgent {
   agent: Agent;
   bookId: string | null;
   modelId: string | null;
+  allowSystemFileRead: boolean;
   lastCommittedSeq: number;
   lastActive: number;
 }
@@ -127,6 +130,13 @@ function modelIdFromSpec(spec: AgentSessionConfig["model"]): string | null {
   return null;
 }
 
+function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
+  if (value === undefined) return defaultValue;
+  if (value === "1" || value.toLowerCase() === "true") return true;
+  if (value === "0" || value.toLowerCase() === "false") return false;
+  return defaultValue;
+}
+
 async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
   const events = await readTranscriptEvents(projectRoot, sessionId);
   return events
@@ -307,6 +317,7 @@ export async function runAgentSession(
   // a spurious cache eviction because `null !== undefined`.
   const bookId: string | null = config.bookId ?? null;
   const requestedModelId = modelIdFromSpec(config.model);
+  const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, true);
 
   // ----- Resolve or create Agent -----
   let cached = agentCache.get(sessionId);
@@ -323,8 +334,9 @@ export async function runAgentSession(
       cached.modelId !== requestedModelId
     );
     const bookChanged = cached.bookId !== bookId;
+    const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
 
-    if (modelChanged || bookChanged) {
+    if (modelChanged || bookChanged || readPermissionChanged) {
       agentCache.delete(sessionId);
       cached = undefined;
     }
@@ -344,7 +356,7 @@ export async function runAgentSession(
         systemPrompt: buildAgentSystemPrompt(bookId, language),
         tools: [
           createSubAgentTool(pipeline, bookId, projectRoot),
-          createReadTool(projectRoot),
+          createReadTool(projectRoot, { allowSystemPaths: allowSystemFileRead }),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),
           createRenameEntityTool(pipeline, projectRoot, bookId),
           createPatchChapterTextTool(pipeline, projectRoot, bookId),
@@ -367,6 +379,7 @@ export async function runAgentSession(
       agent,
       bookId,
       modelId: model.id ?? requestedModelId,
+      allowSystemFileRead,
       lastCommittedSeq: await latestCommittedSeq(projectRoot, sessionId),
       lastActive: Date.now(),
     };

+ 22 - 5
packages/core/src/agent/agent-tools.ts

@@ -3,7 +3,7 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mario
 import type { PipelineRunner } from "../pipeline/runner.js";
 import { type ReviseMode } from "../agents/reviser.js";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
-import { join, normalize, resolve } from "node:path";
+import { isAbsolute, join, normalize, resolve } from "node:path";
 import { StateManager } from "../state/manager.js";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 import { writeExportArtifact } from "../interaction/export-artifact.js";
@@ -340,15 +340,32 @@ export function createPatchChapterTextTool(
 // ---------------------------------------------------------------------------
 
 const ReadParams = Type.Object({
-  path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
+  path: Type.String({ description: "File path relative to books/, or an absolute path when system path reading is enabled." }),
 });
 
-export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
+export interface ReadToolOptions {
+  readonly allowSystemPaths?: boolean;
+}
+
+function resolveReadPath(booksRoot: string, requestedPath: string, options: ReadToolOptions): string {
+  if (options.allowSystemPaths && isAbsolute(requestedPath)) {
+    return resolve(requestedPath);
+  }
+  return safeBooksPath(booksRoot, requestedPath);
+}
+
+export function createReadTool(
+  projectRoot: string,
+  options: ReadToolOptions = {},
+): AgentTool<typeof ReadParams> {
   const booksRoot = join(projectRoot, "books");
+  const description = options.allowSystemPaths
+    ? "Read a file. Relative paths resolve under books/; absolute paths read from the system filesystem."
+    : "Read a file from the book directory. Path is relative to books/.";
 
   return {
     name: "read",
-    description: "Read a file from the book directory. Path is relative to books/.",
+    description,
     label: "Read File",
     parameters: ReadParams,
     async execute(
@@ -356,7 +373,7 @@ export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams
       params: Static<typeof ReadParams>,
     ): Promise<AgentToolResult<undefined>> {
       try {
-        const filePath = safeBooksPath(booksRoot, params.path);
+        const filePath = resolveReadPath(booksRoot, params.path, options);
         let content = await readFile(filePath, "utf-8");
         if (content.length > 10_000) {
           content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";