mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): lock sandbox tmp media paths to openclaw roots
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads.
|
||||
- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
|
||||
- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import Ajv from "ajv";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
// NOTE: This extension is intended to be bundled with OpenClaw.
|
||||
// When running from source (tests/dev), OpenClaw internals live under src/.
|
||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
||||
@@ -180,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
try {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-"));
|
||||
tmpDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"),
|
||||
);
|
||||
const sessionId = `llm-task-${Date.now()}`;
|
||||
const sessionFile = path.join(tmpDir, "session.json");
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused",
|
||||
@@ -93,6 +93,7 @@
|
||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
|
||||
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
|
||||
173
scripts/check-no-random-messaging-tmp.mjs
Normal file
173
scripts/check-no-random-messaging-tmp.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceRoots = [
|
||||
path.join(repoRoot, "src", "channels"),
|
||||
path.join(repoRoot, "src", "infra", "outbound"),
|
||||
path.join(repoRoot, "src", "line"),
|
||||
path.join(repoRoot, "src", "media-understanding"),
|
||||
path.join(repoRoot, "extensions"),
|
||||
];
|
||||
const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]);
|
||||
|
||||
function isTestLikeFile(filePath) {
|
||||
return (
|
||||
filePath.endsWith(".test.ts") ||
|
||||
filePath.endsWith(".test-utils.ts") ||
|
||||
filePath.endsWith(".test-harness.ts") ||
|
||||
filePath.endsWith(".e2e-harness.ts")
|
||||
);
|
||||
}
|
||||
|
||||
async function collectTypeScriptFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...(await collectTypeScriptFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!entryPath.endsWith(".ts")) {
|
||||
continue;
|
||||
}
|
||||
if (isTestLikeFile(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
out.push(entryPath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectNodeOsImports(sourceFile) {
|
||||
const osNamespaceOrDefault = new Set();
|
||||
const namedTmpdir = new Set();
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isImportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
||||
continue;
|
||||
}
|
||||
if (statement.moduleSpecifier.text !== "node:os") {
|
||||
continue;
|
||||
}
|
||||
const clause = statement.importClause;
|
||||
if (clause.name) {
|
||||
osNamespaceOrDefault.add(clause.name.text);
|
||||
}
|
||||
if (!clause.namedBindings) {
|
||||
continue;
|
||||
}
|
||||
if (ts.isNamespaceImport(clause.namedBindings)) {
|
||||
osNamespaceOrDefault.add(clause.namedBindings.name.text);
|
||||
continue;
|
||||
}
|
||||
for (const element of clause.namedBindings.elements) {
|
||||
if ((element.propertyName?.text ?? element.name.text) === "tmpdir") {
|
||||
namedTmpdir.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { osNamespaceOrDefault, namedTmpdir };
|
||||
}
|
||||
|
||||
function unwrapExpression(expression) {
|
||||
let current = expression;
|
||||
while (true) {
|
||||
if (ts.isParenthesizedExpression(current)) {
|
||||
current = current.expression;
|
||||
continue;
|
||||
}
|
||||
if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) {
|
||||
current = current.expression;
|
||||
continue;
|
||||
}
|
||||
if (ts.isNonNullExpression(current)) {
|
||||
current = current.expression;
|
||||
continue;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile);
|
||||
const lines = [];
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = unwrapExpression(node.expression);
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === "tmpdir" &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
osNamespaceOrDefault.has(callee.expression.text)
|
||||
) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1;
|
||||
lines.push(line);
|
||||
} else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1;
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const files = (
|
||||
await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir)))
|
||||
).flat();
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
if (allowedCallsites.has(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
for (const line of findMessagingTmpdirCallLines(content, filePath)) {
|
||||
violations.push(`${path.relative(repoRoot, filePath)}:${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation}`);
|
||||
}
|
||||
console.error(
|
||||
"Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isDirectExecution = (() => {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
||||
})();
|
||||
|
||||
if (isDirectExecution) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { resolveSandboxedMediaSource } from "./sandbox-paths.js";
|
||||
|
||||
async function withSandboxRoot<T>(run: (sandboxDir: string) => Promise<T>) {
|
||||
@@ -24,22 +25,24 @@ function isPathInside(root: string, target: string): boolean {
|
||||
}
|
||||
|
||||
describe("resolveSandboxedMediaSource", () => {
|
||||
const openClawTmpDir = resolvePreferredOpenClawTmpDir();
|
||||
|
||||
// Group 1: /tmp paths (the bug fix)
|
||||
it.each([
|
||||
{
|
||||
name: "absolute paths under os.tmpdir()",
|
||||
media: path.join(os.tmpdir(), "image.png"),
|
||||
expected: path.join(os.tmpdir(), "image.png"),
|
||||
name: "absolute paths under preferred OpenClaw tmp root",
|
||||
media: path.join(openClawTmpDir, "image.png"),
|
||||
expected: path.join(openClawTmpDir, "image.png"),
|
||||
},
|
||||
{
|
||||
name: "file:// URLs pointing to os.tmpdir()",
|
||||
media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href,
|
||||
expected: path.join(os.tmpdir(), "photo.png"),
|
||||
name: "file:// URLs pointing to preferred OpenClaw tmp root",
|
||||
media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href,
|
||||
expected: path.join(openClawTmpDir, "photo.png"),
|
||||
},
|
||||
{
|
||||
name: "nested paths under os.tmpdir()",
|
||||
media: path.join(os.tmpdir(), "subdir", "deep", "file.png"),
|
||||
expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"),
|
||||
name: "nested paths under preferred OpenClaw tmp root",
|
||||
media: path.join(openClawTmpDir, "subdir", "deep", "file.png"),
|
||||
expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"),
|
||||
},
|
||||
])("allows $name", async ({ media, expected }) => {
|
||||
await withSandboxRoot(async (sandboxDir) => {
|
||||
@@ -96,7 +99,12 @@ describe("resolveSandboxedMediaSource", () => {
|
||||
},
|
||||
{
|
||||
name: "path traversal through tmpdir",
|
||||
media: path.join(os.tmpdir(), "..", "etc", "passwd"),
|
||||
media: path.join(openClawTmpDir, "..", "etc", "passwd"),
|
||||
expected: /sandbox/i,
|
||||
},
|
||||
{
|
||||
name: "absolute paths under host tmp outside openclaw tmp root",
|
||||
media: path.join(os.tmpdir(), "outside-openclaw", "passwd"),
|
||||
expected: /sandbox/i,
|
||||
},
|
||||
{
|
||||
@@ -120,20 +128,25 @@ describe("resolveSandboxedMediaSource", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects symlinked tmpdir paths escaping tmpdir", async () => {
|
||||
it("rejects symlinked OpenClaw tmp paths escaping tmp root", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const outsideTmpTarget = path.resolve(process.cwd(), "package.json");
|
||||
if (isPathInside(os.tmpdir(), outsideTmpTarget)) {
|
||||
if (isPathInside(openClawTmpDir, outsideTmpTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withSandboxRoot(async (sandboxDir) => {
|
||||
await fs.access(outsideTmpTarget);
|
||||
const symlinkPath = path.join(sandboxDir, "tmp-link-escape");
|
||||
await fs.mkdir(openClawTmpDir, { recursive: true });
|
||||
const symlinkPath = path.join(openClawTmpDir, `tmp-link-escape-${process.pid}`);
|
||||
await fs.symlink(outsideTmpTarget, symlinkPath);
|
||||
await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i);
|
||||
try {
|
||||
await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i);
|
||||
} finally {
|
||||
await fs.unlink(symlinkPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
const HTTP_URL_RE = /^https?:\/\//i;
|
||||
@@ -181,11 +182,11 @@ async function resolveAllowedTmpMediaPath(params: {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot));
|
||||
const tmpDir = path.resolve(os.tmpdir());
|
||||
if (!isPathInside(tmpDir, resolved)) {
|
||||
const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
if (!isPathInside(openClawTmpDir, resolved)) {
|
||||
return undefined;
|
||||
}
|
||||
await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir);
|
||||
await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
@@ -202,6 +203,86 @@ describe("deliverOutboundPayloads", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: telegramChunkConfig,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
|
||||
deps: { sendTelegram },
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hi",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => {
|
||||
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: { channels: { signal: {} } },
|
||||
channel: "signal",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
|
||||
deps: { sendSignal },
|
||||
});
|
||||
|
||||
expect(sendSignal).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"hi",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"hi",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "imessage",
|
||||
to: "imessage:+15551234567",
|
||||
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"imessage:+15551234567",
|
||||
"hi",
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses signal media maxBytes from config", async () => {
|
||||
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
||||
const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } };
|
||||
|
||||
@@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
vi.mock("../../web/media.js", async () => {
|
||||
@@ -622,10 +623,12 @@ describe("runMessageAction sandboxed media validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows media paths under os.tmpdir()", async () => {
|
||||
it("allows media paths under preferred OpenClaw tmp root", async () => {
|
||||
const tmpRoot = resolvePreferredOpenClawTmpDir();
|
||||
await fs.mkdir(tmpRoot, { recursive: true });
|
||||
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
|
||||
try {
|
||||
const tmpFile = path.join(os.tmpdir(), "test-media-image.png");
|
||||
const tmpFile = path.join(tmpRoot, "test-media-image.png");
|
||||
const result = await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
@@ -644,6 +647,21 @@ describe("runMessageAction sandboxed media validation", () => {
|
||||
throw new Error("expected send result");
|
||||
}
|
||||
expect(result.sendResult?.mediaUrl).toBe(tmpFile);
|
||||
const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png");
|
||||
await expect(
|
||||
runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
media: hostTmpOutsideOpenClaw,
|
||||
message: "",
|
||||
},
|
||||
sandboxRoot: sandboxDir,
|
||||
dryRun: true,
|
||||
}),
|
||||
).rejects.toThrow(/sandbox/i);
|
||||
} finally {
|
||||
await fs.rm(sandboxDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
collectProviderApiKeysForExecution,
|
||||
@@ -14,6 +13,7 @@ import type {
|
||||
MediaUnderstandingModelConfig,
|
||||
} from "../config/types.tools.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { MediaAttachmentCache } from "./attachments.js";
|
||||
import {
|
||||
@@ -566,7 +566,9 @@ export async function runCliEntry(params: {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
});
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cli-"));
|
||||
const outputDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-cli-"),
|
||||
);
|
||||
const mediaPath = pathResult.path;
|
||||
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ export { createLoggerBackedRuntime } from "./runtime.js";
|
||||
export { chunkTextForOutbound } from "./text-chunking.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
||||
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
export {
|
||||
runPluginCommandWithTimeout,
|
||||
type PluginCommandRunOptions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
||||
|
||||
describe("buildRandomTempFilePath", () => {
|
||||
@@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => {
|
||||
});
|
||||
|
||||
it("sanitizes prefix and extension to avoid path traversal segments", () => {
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
const result = buildRandomTempFilePath({
|
||||
prefix: "../../line/../media",
|
||||
extension: "/../.jpg",
|
||||
now: 123,
|
||||
uuid: "abc",
|
||||
});
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
const resolved = path.resolve(result);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
@@ -45,11 +45,12 @@ describe("withTempDownloadPath", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-"));
|
||||
expect(capturedPath).toContain(path.join(resolvePreferredOpenClawTmpDir(), "line-media-"));
|
||||
await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("sanitizes prefix and fileName", async () => {
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
let capturedPath = "";
|
||||
await withTempDownloadPath(
|
||||
{
|
||||
@@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
const resolved = path.resolve(capturedPath);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
function sanitizePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
@@ -27,6 +27,10 @@ function sanitizeFileName(fileName: string): string {
|
||||
return normalized || "download.bin";
|
||||
}
|
||||
|
||||
function resolveTempRoot(tmpDir?: string): string {
|
||||
return tmpDir ?? resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
export function buildRandomTempFilePath(params: {
|
||||
prefix: string;
|
||||
extension?: string;
|
||||
@@ -42,7 +46,7 @@ export function buildRandomTempFilePath(params: {
|
||||
? Math.trunc(nowCandidate)
|
||||
: Date.now();
|
||||
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
||||
return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`);
|
||||
return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`);
|
||||
}
|
||||
|
||||
export async function withTempDownloadPath<T>(
|
||||
@@ -53,7 +57,7 @@ export async function withTempDownloadPath<T>(
|
||||
},
|
||||
fn: (tmpPath: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const tempRoot = params.tmpDir ?? os.tmpdir();
|
||||
const tempRoot = resolveTempRoot(params.tmpDir);
|
||||
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
||||
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
|
||||
|
||||
36
test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
36
test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs";
|
||||
|
||||
describe("check-no-random-messaging-tmp", () => {
|
||||
it("finds os.tmpdir calls imported from node:os", () => {
|
||||
const source = `
|
||||
import os from "node:os";
|
||||
const dir = os.tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("finds tmpdir named import calls from node:os", () => {
|
||||
const source = `
|
||||
import { tmpdir } from "node:os";
|
||||
const dir = tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("ignores mentions in comments and strings", () => {
|
||||
const source = `
|
||||
// os.tmpdir()
|
||||
const text = "tmpdir()";
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores tmpdir symbols that are not imported from node:os", () => {
|
||||
const source = `
|
||||
const tmpdir = () => "/tmp";
|
||||
const dir = tmpdir();
|
||||
`;
|
||||
expect(findMessagingTmpdirCallLines(source)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user