fix(feishu): enforce workspace-only localRoots in docx upload actions [AI-assisted] (#62369)

* fix: address issue

* docs(changelog): add feishu workspace-only docx entry

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
pgondhi987
2026-04-07 22:05:03 +05:30
committed by GitHub
parent 67a3af7f8d
commit f0c9978030
9 changed files with 239 additions and 26 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
},
};
},
}));