summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-read-file/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:12 +0900
commit7fb3269c698ae583ea7997ce206c4ae252fd3218 (patch)
tree247d03408ecccd633290ea56b1b08811ebe460ec /packages/tool-read-file/src
parent4283d1f8a0bc3953e65962a2364c903d0015f047 (diff)
downloaddispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.tar.gz
dispatch-7fb3269c698ae583ea7997ce206c4ae252fd3218.zip
feat(backend): credential-store + model selection/catalog (GET /models) + per-turn cwd through orchestrator/transport/host-bin
Diffstat (limited to 'packages/tool-read-file/src')
-rw-r--r--packages/tool-read-file/src/read-file.test.ts68
-rw-r--r--packages/tool-read-file/src/read-file.ts21
2 files changed, 79 insertions, 10 deletions
diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts
index f995b09..2725a05 100644
--- a/packages/tool-read-file/src/read-file.test.ts
+++ b/packages/tool-read-file/src/read-file.test.ts
@@ -11,7 +11,7 @@ import {
validateArgs,
} from "./read-file.js";
-function stubCtx(): ToolExecuteContext {
+function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
return {
toolCallId: "test-call-1",
onOutput: () => {},
@@ -21,6 +21,7 @@ function stubCtx(): ToolExecuteContext {
{ emit: () => {} },
{ now: () => 0, newId: () => "id" },
),
+ ...overrides,
};
}
@@ -250,4 +251,69 @@ describe("createReadFileTool", () => {
expect(tool.parameters.required).toEqual(["path"]);
expect(tool.parameters.properties?.path?.type).toBe("string");
});
+
+ it("reads file under ctx.cwd when set (not baked workdir)", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const filePath = join(ctxDir, "ctx-file.txt");
+ await writeFile(filePath, "from ctx cwd", "utf8");
+
+ const tool = createReadFileTool(workdir); // baked workdir is different
+ const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("1: from ctx cwd");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects path escaping ctx.cwd via ..", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-escape-test-"));
+ try {
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "../escape.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects symlink escaping ctx.cwd", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-symlink-test-"));
+ const outsideDir = await mkdtemp(join(tmpdir(), "ctx-outside-"));
+ try {
+ const outsideFile = join(outsideDir, "secret.txt");
+ await writeFile(outsideFile, "secret data", "utf8");
+
+ const symlinkPath = join(ctxDir, "link.txt");
+ const { symlink } = await import("node:fs/promises");
+ await symlink(outsideFile, symlinkPath);
+
+ const tool = createReadFileTool(workdir);
+ const result = await tool.execute({ path: "link.txt" }, stubCtx({ cwd: ctxDir }));
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("outside the working directory");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ await rm(outsideDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ const filePath = join(workdir, "baked-file.txt");
+ await writeFile(filePath, "from baked workdir", "utf8");
+
+ const tool = createReadFileTool(workdir);
+ const ctx = stubCtx();
+ // Ensure cwd is undefined
+ expect(ctx.cwd).toBeUndefined();
+ const result = await tool.execute({ path: "baked-file.txt" }, ctx);
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("1: from baked workdir");
+ });
});
diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts
index b5bb0f1..d4a4de8 100644
--- a/packages/tool-read-file/src/read-file.ts
+++ b/packages/tool-read-file/src/read-file.ts
@@ -103,7 +103,7 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
required: ["path"],
},
concurrencySafe: true,
- async execute(args: unknown, _ctx): Promise<ToolResult> {
+ async execute(args: unknown, ctx): Promise<ToolResult> {
const validated = validateArgs(args);
if ("error" in validated) {
return { content: validated.error, isError: true };
@@ -111,11 +111,14 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
const { path: relPath, offset, limit } = validated;
- // Resolve the requested path against the working directory.
- const resolvedPath = resolve(workdir, relPath);
+ // Effective base: per-turn ctx.cwd overrides the baked workdir.
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
- // Basic prefix check (catches ".." and absolute paths outside workdir).
- if (!isPathWithinWorkdir(resolvedPath, workdir)) {
+ // Resolve the requested path against the effective base.
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ // Basic prefix check (catches ".." and absolute paths outside effectiveBase).
+ if (!isPathWithinWorkdir(resolvedPath, effectiveBase)) {
return {
content: `Error: Path "${relPath}" is outside the working directory.`,
isError: true,
@@ -124,11 +127,11 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
// Symlink hardening: realpath both and re-check containment.
let realResolved: string;
- let realWorkdir: string;
+ let realBase: string;
try {
- [realResolved, realWorkdir] = await Promise.all([
+ [realResolved, realBase] = await Promise.all([
realpath(resolvedPath),
- realpath(workdir),
+ realpath(effectiveBase),
]);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
@@ -141,7 +144,7 @@ export function createReadFileTool(workingDirectory: string): ToolContract {
};
}
- if (!isPathWithinWorkdir(realResolved, realWorkdir)) {
+ if (!isPathWithinWorkdir(realResolved, realBase)) {
return {
content: `Error: Path "${relPath}" is outside the working directory.`,
isError: true,