diff --git a/CHANGELOG.md b/CHANGELOG.md index 72668f0bc6a..42d5be89614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman. - Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman. - Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus. +- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987. - Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678) - Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07. - Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987. diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index c37c5d61f51..5eb8013c236 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -1,3 +1,5 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-harness.js"; @@ -471,11 +473,11 @@ describe("feishu_doc image fetch hardening", () => { expect(result.details.file_token).toBe("token_1"); expect(result.details.file_name).toBe("test-local.txt"); - // localRoots is not passed — loadWebMedia uses default roots (tmp, media, - // workspace, sandboxes) plus workspace-profile auto-discovery. + // Without workspace-only policy, localRoots stays undefined so loadWebMedia + // applies its default managed-root access behavior. expect(loadWebMediaMock).toHaveBeenCalledWith( expect.stringContaining("test-local.txt"), - expect.objectContaining({ optimizeImages: false }), + expect.objectContaining({ optimizeImages: false, localRoots: undefined }), ); expect(driveUploadAllMock).toHaveBeenCalledWith( @@ -489,6 +491,124 @@ describe("feishu_doc image fetch hardening", () => { ); }); + it("passes workspace localRoots for upload_file when workspace-only policy is active", async () => { + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 23, block_id: "file_block_1" }], + }, + }); + + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "test-local.txt", + }); + + const feishuDocTool = resolveFeishuDocTool({ + workspaceDir: "/workspace", + fsPolicy: { workspaceOnly: true }, + }); + + await executeFeishuDocTool(feishuDocTool, { + action: "upload_file", + doc_token: "doc_1", + file_path: "/tmp/openclaw-1000/test-local.txt", + filename: "test-local.txt", + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + expect.stringContaining("test-local.txt"), + expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }), + ); + }); + + it("passes empty localRoots when workspace-only policy is active without workspaceDir", async () => { + blockChildrenCreateMock.mockResolvedValueOnce({ + code: 0, + data: { + children: [{ block_type: 23, block_id: "file_block_1" }], + }, + }); + + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "test-local.txt", + }); + + const feishuDocTool = resolveFeishuDocTool({ + fsPolicy: { workspaceOnly: true }, + }); + + await executeFeishuDocTool(feishuDocTool, { + action: "upload_file", + doc_token: "doc_1", + file_path: "/tmp/openclaw-1000/test-local.txt", + filename: "test-local.txt", + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + expect.stringContaining("test-local.txt"), + expect.objectContaining({ optimizeImages: false, localRoots: [] }), + ); + }); + + it("passes workspace localRoots for upload_image local paths when workspace-only policy is active", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "test-local.png", + }); + + const feishuDocTool = resolveFeishuDocTool({ + workspaceDir: "/workspace", + fsPolicy: { workspaceOnly: true }, + }); + + await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + image: "./test-local.png", + filename: "test-local.png", + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + expect.stringContaining("test-local.png"), + expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }), + ); + }); + + it("passes workspace localRoots for upload_image absolute local paths when workspace-only policy is active", async () => { + const fixtureDir = path.join(process.cwd(), ".tmp-docx-upload-image-absolute"); + const absoluteImagePath = path.join(fixtureDir, "absolute-image.png"); + mkdirSync(fixtureDir, { recursive: true }); + writeFileSync(absoluteImagePath, "not-real-image"); + + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("hello from local file", "utf8"), + fileName: "absolute-image.png", + }); + + const feishuDocTool = resolveFeishuDocTool({ + workspaceDir: "/workspace", + fsPolicy: { workspaceOnly: true }, + }); + + try { + await executeFeishuDocTool(feishuDocTool, { + action: "upload_image", + doc_token: "doc_1", + image: absoluteImagePath, + filename: "absolute-image.png", + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith( + expect.stringContaining("absolute-image.png"), + expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }), + ); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + it("returns an error when upload_file cannot list placeholder siblings", async () => { blockChildrenCreateMock.mockResolvedValueOnce({ code: 0, diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 4e5dfc8df11..c12b5748afe 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -36,6 +36,24 @@ function json(data: unknown) { }; } +function resolveDocToolLocalRoots(ctx: { + workspaceDir?: string; + fsPolicy?: { workspaceOnly: boolean }; +}): string[] | undefined { + if (ctx.fsPolicy?.workspaceOnly !== true) { + return undefined; + } + const workspaceDir = ctx.workspaceDir?.trim(); + // Fail closed: workspace-only with no resolved workspace must not fall back + // to default managed roots. + if (!workspaceDir) { + return []; + } + // Workspace paths are expected to be absolute; resolve() normalizes any + // accidental relative input before passing roots to loadWebMedia. + return [resolve(workspaceDir)]; +} + /** Extract image URLs from markdown content */ function extractImageUrls(markdown: string): string[] { const regex = /!\[[^\]]*\]\(([^)]+)\)/g; @@ -520,6 +538,7 @@ async function resolveUploadInput( url: string | undefined, filePath: string | undefined, maxBytes: number, + localRoots?: readonly string[], explicitFileName?: string, imageInput?: string, // data URI, plain base64, or local path ): Promise<{ buffer: Buffer; fileName: string }> { @@ -585,12 +604,11 @@ async function resolveUploadInput( if (unambiguousPath || (absolutePath && existsSync(candidate))) { // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). - // localRoots left undefined so loadWebMedia uses default roots (tmp, media, - // workspace, sandboxes) plus workspace-profile auto-discovery. const resolvedPath = resolve(candidate); const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedPath, { maxBytes, optimizeImages: false, + localRoots, }); return { buffer: loaded.buffer, fileName: explicitFileName ?? basename(candidate) }; } @@ -647,11 +665,11 @@ async function resolveUploadInput( } // Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu). - // localRoots left undefined — see comment above. const resolvedFilePath = resolve(filePath!); const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, { maxBytes, optimizeImages: false, + localRoots, }); return { buffer: loaded.buffer, @@ -707,6 +725,7 @@ async function uploadImageBlock( client: Lark.Client, docToken: string, maxBytes: number, + localRoots?: readonly string[], url?: string, filePath?: string, parentBlockId?: string, @@ -730,7 +749,14 @@ async function uploadImageBlock( } // Step 2: Resolve and upload the image buffer. - const upload = await resolveUploadInput(url, filePath, maxBytes, filename, imageInput); + const upload = await resolveUploadInput( + url, + filePath, + maxBytes, + localRoots, + filename, + imageInput, + ); const fileToken = await uploadImageToDocx( client, imageBlockId, @@ -761,6 +787,7 @@ async function uploadFileBlock( client: Lark.Client, docToken: string, maxBytes: number, + localRoots?: readonly string[], url?: string, filePath?: string, parentBlockId?: string, @@ -771,7 +798,7 @@ async function uploadFileBlock( // Feishu API does not allow creating empty file blocks (block_type 23). // Workaround: create a placeholder text block, then replace it with file content. // Actually, file blocks need a different approach: use markdown link as placeholder. - const upload = await resolveUploadInput(url, filePath, maxBytes, filename); + const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename); // Create a placeholder text block first const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`; @@ -1393,6 +1420,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { api.registerTool( (ctx) => { const defaultAccountId = ctx.agentAccountId; + const mediaLocalRoots = resolveDocToolLocalRoots(ctx); const trustedRequesterOpenId = ctx.messageChannel === "feishu" ? normalizeOptionalString(ctx.requesterSenderId) @@ -1489,6 +1517,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { client, p.doc_token, getMediaMaxBytes(p, defaultAccountId), + mediaLocalRoots, p.url, p.file_path, p.parent_block_id, @@ -1503,6 +1532,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) { client, p.doc_token, getMediaMaxBytes(p, defaultAccountId), + mediaLocalRoots, p.url, p.file_path, p.parent_block_id, diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index e3f26ec0a1f..760de775cca 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -2,29 +2,18 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; -import type { GatewayMessageChannel } from "../utils/message-channel.js"; -import { resolveOpenClawPluginToolInputs } from "./openclaw-tools.plugin-context.js"; +import { + resolveOpenClawPluginToolInputs, + type OpenClawPluginToolOptions, +} from "./openclaw-tools.plugin-context.js"; import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js"; import type { AnyAgentTool } from "./tools/common.js"; -type ResolveOpenClawPluginToolsOptions = { - config?: OpenClawConfig; +type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & { pluginToolAllowlist?: string[]; - agentChannel?: GatewayMessageChannel; - agentAccountId?: string; - agentTo?: string; - agentThreadId?: string | number; - requesterSenderId?: string | null; - senderIsOwner?: boolean; - allowGatewaySubagentBinding?: boolean; - sandboxed?: boolean; - agentSessionKey?: string; - sessionId?: string; currentChannelId?: string; currentThreadTs?: string; currentMessageId?: string | number; - workspaceDir?: string; - agentDir?: string; sandboxRoot?: string; modelHasVision?: boolean; modelProvider?: string; diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index d18e0f593d0..15e9f21b460 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -67,4 +67,54 @@ describe("createOpenClawTools browser plugin integration", () => { expect(tools.map((tool) => tool.name)).not.toContain("browser"); }); + + it("forwards fsPolicy into plugin tool context", async () => { + let capturedContext: { fsPolicy?: { workspaceOnly: boolean } } | undefined; + hoisted.resolvePluginTools.mockImplementation((params: unknown) => { + const resolvedParams = params as { context?: { fsPolicy?: { workspaceOnly: boolean } } }; + capturedContext = resolvedParams.context; + return [ + { + name: "browser", + description: "browser fixture tool", + parameters: { + type: "object", + properties: {}, + }, + async execute() { + return { + content: [{ type: "text", text: "ok" }], + details: { workspaceOnly: capturedContext?.fsPolicy?.workspaceOnly ?? null }, + }; + }, + }, + ]; + }); + + const tools = resolveOpenClawPluginToolsForOptions({ + options: { + config: { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + fsPolicy: { workspaceOnly: true }, + }, + resolvedConfig: { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig, + }); + + const browserTool = tools.find((tool) => tool.name === "browser"); + expect(browserTool).toBeDefined(); + if (!browserTool) { + throw new Error("expected browser tool"); + } + + const result = await browserTool.execute("tool-call", {}); + const details = (result.details ?? {}) as { workspaceOnly?: boolean | null }; + expect(details.workspaceOnly).toBe(true); + }); }); diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 6133c1b05eb..2c83f7b9b5c 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -22,6 +22,21 @@ describe("openclaw plugin tool context", () => { ); }); + it("forwards fs policy for plugin tool sandbox enforcement", () => { + const result = resolveOpenClawPluginToolInputs({ + options: { + config: {} as never, + fsPolicy: { workspaceOnly: true }, + }, + }); + + expect(result.context).toEqual( + expect.objectContaining({ + fsPolicy: { workspaceOnly: true }, + }), + ); + }); + it("forwards ephemeral sessionId", () => { const result = resolveOpenClawPluginToolInputs({ options: { diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index 083e707af61..7b7f2326a54 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; +import type { ToolFsPolicy } from "./tool-fs-policy.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; export type OpenClawPluginToolOptions = { @@ -13,6 +14,7 @@ export type OpenClawPluginToolOptions = { agentDir?: string; workspaceDir?: string; config?: OpenClawConfig; + fsPolicy?: ToolFsPolicy; requesterSenderId?: string | null; senderIsOwner?: boolean; sessionId?: string; @@ -48,6 +50,7 @@ export function resolveOpenClawPluginToolInputs(params: { context: { config: options?.config, runtimeConfig, + fsPolicy: options?.fsPolicy, workspaceDir, agentDir: options?.agentDir, agentId: sessionAgentId, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index bf23cbfca6a..1ec67597e22 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -15,6 +15,7 @@ import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js"; import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; import type { PromptMode } from "../agents/system-prompt.js"; +import type { ToolFsPolicy } from "../agents/tool-fs-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ReplyDispatchKind, ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; @@ -141,6 +142,8 @@ export type OpenClawPluginToolContext = { config?: OpenClawConfig; /** Active runtime-resolved config snapshot when one is available. */ runtimeConfig?: OpenClawConfig; + /** Effective filesystem policy for the active tool run. */ + fsPolicy?: ToolFsPolicy; workspaceDir?: string; agentDir?: string; agentId?: string; diff --git a/test/helpers/browser-bundled-plugin-fixture.ts b/test/helpers/browser-bundled-plugin-fixture.ts index c54fdf15688..16eccd37299 100644 --- a/test/helpers/browser-bundled-plugin-fixture.ts +++ b/test/helpers/browser-bundled-plugin-fixture.ts @@ -22,7 +22,7 @@ const BROWSER_FIXTURE_ENTRY = `module.exports = { properties: {}, }, register(api) { - api.registerTool(() => ({ + api.registerTool((ctx) => ({ name: "browser", label: "browser", description: "browser fixture tool", @@ -33,7 +33,9 @@ const BROWSER_FIXTURE_ENTRY = `module.exports = { async execute() { return { content: [{ type: "text", text: "ok" }], - details: {}, + details: { + workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null, + }, }; }, }));