Skip to content

Commit fb6ca67

Browse files
clawsweeper-repairomarshahine
andcommitted
fix(file-transfer): require canonical node policy authorization
Co-authored-by: Omar Shahine <[email protected]>
1 parent bce476b commit fb6ca67

5 files changed

Lines changed: 321 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
4343
- Plugins/runtime-deps: verify staged package entry files before reusing mirrored runtime roots, so browser-control repairs incomplete `ajv`/MCP SDK installs after update instead of failing after restart on a missing `ajv/dist/ajv.js`. Refs #74630. Thanks @spickeringlr.
4444
- Heartbeat: resolve `responsePrefix` template variables with the selected provider, model, and thinking context before delivering alerts or suppressing prefixed `HEARTBEAT_OK` replies. Fixes #43064; repairs #43065; supersedes #46858. Thanks @yweiii and @JunJD.
4545
- Memory/LanceDB: show full memory UUIDs in the `memory_forget` candidate list so agents can pass the displayed ID back to targeted deletion without hitting the full-UUID validator. (#66913) Thanks @amittell.
46+
- File-transfer plugin: require canonical read-path preflight authorization for `file.fetch` and fail closed when `dir.fetch` preflight entries are missing, absolute, or traversing before archive bytes are requested from the node. Carries forward #74134. Thanks @omarshahine.
4647
- Channels/Feishu: retry file-typed iOS video resource downloads as `media` after a Feishu/Lark HTTP 502 and preserve the original 502 when the fallback also fails. Fixes #49855; carries forward #50164 and #73986. Thanks @alex-xuweilong.
4748
- Providers/Amazon Bedrock: expose the full Claude Opus 4.7 thinking profile (`xhigh`, `adaptive`, and `max`) for Bedrock model refs, while keeping Opus/Sonnet 4.6 on adaptive-by-default, so `/think` menus and validation match the Anthropic transport behavior. Fixes #74701. Thanks @prasad-yashdeep, @sparkleHazard, @Sanjays2402, and @hclsys.
4849
- Plugins/tokenjuice: compile the bundled plugin against tokenjuice 0.7.0's published OpenClaw host types instead of a local compatibility shim, so package contract drift fails in OpenClaw validation before release. Thanks @vincentkoc.

extensions/file-transfer/src/node-host/file-fetch.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import {
77
FILE_FETCH_DEFAULT_MAX_BYTES,
88
FILE_FETCH_HARD_MAX_BYTES,
@@ -21,6 +21,7 @@ beforeEach(async () => {
2121
});
2222

2323
afterEach(async () => {
24+
vi.restoreAllMocks();
2425
await fs.rm(tmpRoot, { recursive: true, force: true });
2526
});
2627

@@ -105,6 +106,24 @@ describe("handleFileFetch — happy path", () => {
105106
expect(path.basename(r.path)).toBe("hello.txt");
106107
});
107108

109+
it("preflights canonical path and size without reading bytes", async () => {
110+
const target = path.join(tmpRoot, "hello.txt");
111+
await fs.writeFile(target, "hello world\n");
112+
const readFileSpy = vi.spyOn(fs, "readFile");
113+
114+
const r = await handleFileFetch({ path: target, preflightOnly: true });
115+
116+
expect(r).toMatchObject({
117+
ok: true,
118+
path: target,
119+
size: 12,
120+
base64: "",
121+
sha256: "",
122+
preflightOnly: true,
123+
});
124+
expect(readFileSpy).not.toHaveBeenCalled();
125+
});
126+
108127
it("returns a sensible mime type for known extensions", async () => {
109128
const target = path.join(tmpRoot, "readme.md");
110129
await fs.writeFile(target, "# heading\n");

extensions/file-transfer/src/node-host/file-fetch.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type FileFetchParams = {
1111
path?: unknown;
1212
maxBytes?: unknown;
1313
followSymlinks?: unknown;
14+
preflightOnly?: unknown;
1415
};
1516

1617
export type FileFetchOk = {
@@ -20,6 +21,7 @@ export type FileFetchOk = {
2021
mimeType: string;
2122
base64: string;
2223
sha256: string;
24+
preflightOnly?: boolean;
2325
};
2426

2527
export type FileFetchErrCode =
@@ -95,6 +97,7 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
9597

9698
const maxBytes = clampMaxBytes(params.maxBytes);
9799
const followSymlinks = params.followSymlinks === true;
100+
const preflightOnly = params.preflightOnly === true;
98101

99102
let canonical: string;
100103
try {
@@ -156,6 +159,18 @@ export async function handleFileFetch(params: FileFetchParams): Promise<FileFetc
156159
};
157160
}
158161

162+
if (preflightOnly) {
163+
return {
164+
ok: true,
165+
path: canonical,
166+
size: stats.size,
167+
mimeType: "",
168+
base64: "",
169+
sha256: "",
170+
preflightOnly: true,
171+
};
172+
}
173+
159174
let buffer: Buffer;
160175
try {
161176
buffer = await fs.readFile(canonical);

extensions/file-transfer/src/shared/node-invoke-policy.test.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ describe("file-transfer node invoke policy", () => {
7070
const result = await policy.handle(ctx);
7171

7272
expect(result.ok).toBe(true);
73-
expect(invokeNode).toHaveBeenCalledWith({
73+
expect(invokeNode).toHaveBeenNthCalledWith(1, {
74+
params: {
75+
path: "/tmp/file.txt",
76+
maxBytes: 512,
77+
followSymlinks: false,
78+
preflightOnly: true,
79+
},
80+
});
81+
expect(invokeNode).toHaveBeenNthCalledWith(2, {
7482
params: {
7583
path: "/tmp/file.txt",
7684
maxBytes: 512,
@@ -118,7 +126,15 @@ describe("file-transfer node invoke policy", () => {
118126
toolName: "file.fetch",
119127
}),
120128
);
121-
expect(invokeNode).toHaveBeenCalledWith({
129+
expect(invokeNode).toHaveBeenNthCalledWith(1, {
130+
params: {
131+
path: "/tmp/new.txt",
132+
followSymlinks: false,
133+
maxBytes: 256,
134+
preflightOnly: true,
135+
},
136+
});
137+
expect(invokeNode).toHaveBeenNthCalledWith(2, {
122138
params: {
123139
path: "/tmp/new.txt",
124140
followSymlinks: false,
@@ -149,7 +165,7 @@ describe("file-transfer node invoke policy", () => {
149165
});
150166
});
151167

152-
it("rejects a postflight canonical path outside policy", async () => {
168+
it("checks file.fetch canonical policy before requesting bytes", async () => {
153169
const policy = createFileTransferNodeInvokePolicy();
154170
const { ctx, invokeNode } = createCtx({
155171
params: { path: "/tmp/link.txt" },
@@ -167,6 +183,32 @@ describe("file-transfer node invoke policy", () => {
167183
const result = await policy.handle(ctx);
168184

169185
expect(result).toMatchObject({ ok: false, code: "SYMLINK_TARGET_DENIED" });
186+
expect(invokeNode).toHaveBeenCalledTimes(1);
187+
expect(invokeNode).toHaveBeenCalledWith({
188+
params: expect.objectContaining({
189+
path: "/tmp/link.txt",
190+
followSymlinks: false,
191+
preflightOnly: true,
192+
}),
193+
});
194+
});
195+
196+
it("continues file.fetch after preflight without forwarding caller preflightOnly", async () => {
197+
const policy = createFileTransferNodeInvokePolicy();
198+
const { ctx, invokeNode } = createCtx({
199+
params: { path: "/tmp/file.txt", preflightOnly: true },
200+
});
201+
202+
const result = await policy.handle(ctx);
203+
204+
expect(result).toMatchObject({ ok: true });
205+
expect(invokeNode).toHaveBeenCalledTimes(2);
206+
expect(invokeNode).toHaveBeenNthCalledWith(1, {
207+
params: expect.objectContaining({ path: "/tmp/file.txt", preflightOnly: true }),
208+
});
209+
expect(invokeNode).toHaveBeenNthCalledWith(2, {
210+
params: expect.not.objectContaining({ preflightOnly: true }),
211+
});
170212
});
171213

172214
it("checks file.write canonical policy before the mutating node call", async () => {
@@ -302,6 +344,65 @@ describe("file-transfer node invoke policy", () => {
302344
});
303345
});
304346

347+
it("rejects dir.fetch preflight responses without an entry list", async () => {
348+
const policy = createFileTransferNodeInvokePolicy();
349+
const { ctx, invokeNode } = createCtx({
350+
command: "dir.fetch",
351+
params: { path: "/home/me" },
352+
pluginConfig: {
353+
nodes: {
354+
"node-1": {
355+
allowReadPaths: ["/home/me", "/home/me/**"],
356+
},
357+
},
358+
},
359+
});
360+
invokeNode.mockResolvedValueOnce({
361+
ok: true,
362+
payload: {
363+
ok: true,
364+
path: "/home/me",
365+
fileCount: 2,
366+
preflightOnly: true,
367+
},
368+
});
369+
370+
const result = await policy.handle(ctx);
371+
372+
expect(result).toMatchObject({ ok: false, code: "PREFLIGHT_ENTRIES_MISSING" });
373+
expect(invokeNode).toHaveBeenCalledTimes(1);
374+
});
375+
376+
it("rejects invalid dir.fetch preflight entries before requesting the archive", async () => {
377+
const policy = createFileTransferNodeInvokePolicy();
378+
const { ctx, invokeNode } = createCtx({
379+
command: "dir.fetch",
380+
params: { path: "/home/me" },
381+
pluginConfig: {
382+
nodes: {
383+
"node-1": {
384+
allowReadPaths: ["/home/me", "/home/me/**"],
385+
},
386+
},
387+
},
388+
});
389+
invokeNode.mockResolvedValueOnce({
390+
ok: true,
391+
payload: {
392+
ok: true,
393+
path: "/home/me",
394+
entries: ["ok.txt", "/etc/passwd"],
395+
fileCount: 2,
396+
preflightOnly: true,
397+
},
398+
});
399+
400+
const result = await policy.handle(ctx);
401+
402+
expect(result).toMatchObject({ ok: false, code: "PREFLIGHT_ENTRY_INVALID" });
403+
expect(invokeNode).toHaveBeenCalledTimes(1);
404+
});
405+
305406
it("continues dir.fetch after preflight without forwarding caller preflightOnly", async () => {
306407
const policy = createFileTransferNodeInvokePolicy();
307408
const { ctx, invokeNode } = createCtx({

0 commit comments

Comments
 (0)