diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md
index 4adffd7f41..d7a1b63359 100644
--- a/docs/channels/imessage.md
+++ b/docs/channels/imessage.md
@@ -97,6 +97,10 @@ exec ssh -T gateway-host imsg "$@"
cliPath: "~/.openclaw/scripts/imsg-ssh",
remoteHost: "user@gateway-host", // used for SCP attachment fetches
includeAttachments: true,
+ // Optional: override allowed attachment roots.
+ // Defaults include /Users/*/Library/Messages/Attachments
+ attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
+ remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
},
},
}
@@ -105,6 +109,7 @@ exec ssh -T gateway-host imsg "$@"
If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script.
`remoteHost` must be `host` or `user@host` (no spaces or SSH options).
OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`.
+ Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`).
@@ -233,7 +238,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
iMessage supports per-account config under `channels.imessage.accounts`.
- Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, and history settings.
+ Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists.
@@ -244,6 +249,10 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
- inbound attachment ingestion is optional: `channels.imessage.includeAttachments`
- remote attachment paths can be fetched via SCP when `remoteHost` is set
+ - attachment paths must match allowed roots:
+ - `channels.imessage.attachmentRoots` (local)
+ - `channels.imessage.remoteAttachmentRoots` (remote SCP mode)
+ - default root pattern: `/Users/*/Library/Messages/Attachments`
- SCP uses strict host-key checking (`StrictHostKeyChecking=yes`)
- outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB)
@@ -329,6 +338,7 @@ openclaw channels status --probe
Check:
- `channels.imessage.remoteHost`
+ - `channels.imessage.remoteAttachmentRoots`
- SSH/SCP key auth from the gateway host
- host key exists in `~/.ssh/known_hosts` on the gateway host
- remote path readability on the Mac running Messages
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 0ca00016ee..8f31cea128 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -394,6 +394,8 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
historyLimit: 50,
includeAttachments: false,
+ attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
+ remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
mediaMaxMb: 16,
service: "auto",
region: "US",
@@ -405,6 +407,7 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
- Requires Full Disk Access to the Messages DB.
- Prefer `chat_id:` targets. Use `imsg chats --limit 20` to list chats.
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
+- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts
index f938977c66..671c94bb10 100644
--- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts
+++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts
@@ -10,14 +10,19 @@ import {
const sandboxMocks = vi.hoisted(() => ({
ensureSandboxWorkspaceForSession: vi.fn(),
}));
+const childProcessMocks = vi.hoisted(() => ({
+ spawn: vi.fn(),
+}));
vi.mock("../agents/sandbox.js", () => sandboxMocks);
+vi.mock("node:child_process", () => childProcessMocks);
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
afterEach(() => {
vi.restoreAllMocks();
+ childProcessMocks.spawn.mockReset();
});
describe("stageSandboxMedia", () => {
@@ -86,4 +91,31 @@ describe("stageSandboxMedia", () => {
expect(ctx.MediaPath).toBe(sensitiveFile);
});
});
+
+ it("blocks remote SCP staging for non-iMessage attachment paths", async () => {
+ await withSandboxMediaTempHome("openclaw-triggers-remote-block-", async (home) => {
+ const sandboxDir = join(home, "sandboxes", "session");
+ vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
+ workspaceDir: sandboxDir,
+ containerWorkdir: "/work",
+ });
+
+ const { ctx, sessionCtx } = createSandboxMediaContexts("/etc/passwd");
+ ctx.Provider = "imessage";
+ ctx.MediaRemoteHost = "user@gateway-host";
+ sessionCtx.Provider = "imessage";
+ sessionCtx.MediaRemoteHost = "user@gateway-host";
+
+ await stageSandboxMedia({
+ ctx,
+ sessionCtx,
+ cfg: createSandboxMediaStageConfig(home),
+ sessionKey: "agent:main:main",
+ workspaceDir: join(home, "openclaw"),
+ });
+
+ expect(childProcessMocks.spawn).not.toHaveBeenCalled();
+ expect(ctx.MediaPath).toBe("/etc/passwd");
+ });
+ });
});
diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts
index 73a2f69e94..b8f1ff9b5b 100644
--- a/src/auto-reply/reply/stage-sandbox-media.ts
+++ b/src/auto-reply/reply/stage-sandbox-media.ts
@@ -2,14 +2,18 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import type { OpenClawConfig } from "../../config/config.js";
+import type { MsgContext, TemplateContext } from "../templating.js";
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
-import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
+import {
+ isInboundPathAllowed,
+ resolveIMessageRemoteAttachmentRoots,
+} from "../../media/inbound-path-policy.js";
import { getMediaDir } from "../../media/store.js";
import { CONFIG_DIR } from "../../utils.js";
-import type { MsgContext, TemplateContext } from "../templating.js";
export async function stageSandboxMedia(params: {
ctx: MsgContext;
@@ -70,6 +74,10 @@ export async function stageSandboxMedia(params: {
? path.join(effectiveWorkspaceDir, "media", "inbound")
: effectiveWorkspaceDir;
await fs.mkdir(destDir, { recursive: true });
+ const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
+ cfg,
+ accountId: ctx.AccountId,
+ });
const usedNames = new Set();
const staged = new Map(); // absolute source -> relative sandbox path
@@ -83,9 +91,29 @@ export async function stageSandboxMedia(params: {
continue;
}
+ if (
+ ctx.MediaRemoteHost &&
+ !isInboundPathAllowed({
+ filePath: source,
+ roots: remoteAttachmentRoots,
+ })
+ ) {
+ logVerbose(`Blocking remote media staging from disallowed attachment path: ${source}`);
+ continue;
+ }
+
// Local paths must be restricted to the media directory.
if (!ctx.MediaRemoteHost) {
const mediaDir = getMediaDir();
+ if (
+ !isInboundPathAllowed({
+ filePath: source,
+ roots: [mediaDir],
+ })
+ ) {
+ logVerbose(`Blocking attempt to stage media from outside media directory: ${source}`);
+ continue;
+ }
try {
await assertSandboxPath({
filePath: source,
diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts
index 0639bc3eb0..b211b8808a 100644
--- a/src/config/config.schema-regressions.test.ts
+++ b/src/config/config.schema-regressions.test.ts
@@ -63,4 +63,32 @@ describe("config schema regressions", () => {
expect(res.issues[0]?.path).toBe("channels.imessage.remoteHost");
}
});
+
+ it("accepts iMessage attachment root patterns", () => {
+ const res = validateConfigObject({
+ channels: {
+ imessage: {
+ attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
+ remoteAttachmentRoots: ["/Volumes/relay/attachments"],
+ },
+ },
+ });
+
+ expect(res.ok).toBe(true);
+ });
+
+ it("rejects relative iMessage attachment roots", () => {
+ const res = validateConfigObject({
+ channels: {
+ imessage: {
+ attachmentRoots: ["./attachments"],
+ },
+ },
+ });
+
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.issues[0]?.path).toBe("channels.imessage.attachmentRoots.0");
+ }
+ });
});
diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts
index fc4fd4d54b..f3a225bdd8 100644
--- a/src/config/types.imessage.ts
+++ b/src/config/types.imessage.ts
@@ -50,6 +50,10 @@ export type IMessageAccountConfig = {
dms?: Record;
/** Include attachments + reactions in watch payloads. */
includeAttachments?: boolean;
+ /** Allowed local iMessage attachment roots (supports single-segment `*` wildcards). */
+ attachmentRoots?: string[];
+ /** Allowed remote iMessage attachment roots for SCP fetches (supports `*`). */
+ remoteAttachmentRoots?: string[];
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Timeout for probe/RPC operations in milliseconds (default: 10000). */
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 24ac98c584..3bf1fa66ea 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
+import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js";
import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
@@ -819,6 +820,12 @@ export const IMessageAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
includeAttachments: z.boolean().optional(),
+ attachmentRoots: z
+ .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
+ .optional(),
+ remoteAttachmentRoots: z
+ .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
+ .optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
diff --git a/src/imessage/accounts.ts b/src/imessage/accounts.ts
index 764c1dd39e..c9c0447fee 100644
--- a/src/imessage/accounts.ts
+++ b/src/imessage/accounts.ts
@@ -1,6 +1,6 @@
-import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import type { IMessageAccountConfig } from "../config/types.js";
+import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
import { normalizeAccountId } from "../routing/session-key.js";
export type ResolvedIMessageAccount = {
@@ -51,6 +51,8 @@ export function resolveIMessageAccount(params: {
merged.dmPolicy ||
merged.groupPolicy ||
typeof merged.includeAttachments === "boolean" ||
+ (merged.attachmentRoots && merged.attachmentRoots.length > 0) ||
+ (merged.remoteAttachmentRoots && merged.remoteAttachmentRoots.length > 0) ||
typeof merged.mediaMaxMb === "number" ||
typeof merged.textChunkLimit === "number" ||
(merged.groups && Object.keys(merged.groups).length > 0),
diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts
index a21da04a20..80be651930 100644
--- a/src/imessage/monitor/monitor-provider.ts
+++ b/src/imessage/monitor/monitor-provider.ts
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
+import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
@@ -21,6 +22,11 @@ import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
+import {
+ isInboundPathAllowed,
+ resolveIMessageAttachmentRoots,
+ resolveIMessageRemoteAttachmentRoots,
+} from "../../media/inbound-path-policy.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
@@ -40,7 +46,6 @@ import {
} from "./inbound-processing.js";
import { parseIMessageNotification } from "./parse-notification.js";
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
-import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
/**
* Try to detect remote host from an SSH wrapper script like:
@@ -146,6 +151,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
+ const attachmentRoots = resolveIMessageAttachmentRoots({
+ cfg,
+ accountId: accountInfo.accountId,
+ });
+ const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
+ cfg,
+ accountId: accountInfo.accountId,
+ });
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
// Accept only a safe host token to avoid option/argument injection into SCP.
@@ -220,8 +233,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const messageText = (message.text ?? "").trim();
const attachments = includeAttachments ? (message.attachments ?? []) : [];
- // Filter to valid attachments with paths
- const validAttachments = attachments.filter((entry) => entry?.original_path && !entry?.missing);
+ const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
+ const validAttachments = attachments.filter((entry) => {
+ const attachmentPath = entry?.original_path?.trim();
+ if (!attachmentPath || entry?.missing) {
+ return false;
+ }
+ if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) {
+ return true;
+ }
+ logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`);
+ return false;
+ });
const firstAttachment = validAttachments[0];
const mediaPath = firstAttachment?.original_path ?? undefined;
const mediaType = firstAttachment?.mime_type ?? undefined;
@@ -229,7 +252,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
const kind = mediaKindFromMime(mediaType ?? undefined);
- const placeholder = kind ? `` : attachments?.length ? "" : "";
+ const placeholder = kind
+ ? ``
+ : validAttachments.length
+ ? ""
+ : "";
const bodyText = messageText || placeholder;
const storeAllowFrom = await readChannelAllowFromStore("imessage").catch(() => []);
diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts
index 6de29873bb..331122665b 100644
--- a/src/media-understanding/apply.ts
+++ b/src/media-understanding/apply.ts
@@ -1,7 +1,13 @@
import path from "node:path";
-import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/config.js";
+import type {
+ MediaUnderstandingCapability,
+ MediaUnderstandingDecision,
+ MediaUnderstandingOutput,
+ MediaUnderstandingProvider,
+} from "./types.js";
+import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import {
extractFileContentFromSource,
@@ -21,14 +27,9 @@ import {
buildProviderRegistry,
createMediaAttachmentCache,
normalizeMediaAttachments,
+ resolveMediaAttachmentLocalRoots,
runCapability,
} from "./runner.js";
-import type {
- MediaUnderstandingCapability,
- MediaUnderstandingDecision,
- MediaUnderstandingOutput,
- MediaUnderstandingProvider,
-} from "./types.js";
export type ApplyMediaUnderstandingResult = {
outputs: MediaUnderstandingOutput[];
@@ -473,7 +474,9 @@ export async function applyMediaUnderstanding(params: {
const attachments = normalizeMediaAttachments(ctx);
const providerRegistry = buildProviderRegistry(params.providers);
- const cache = createMediaAttachmentCache(attachments);
+ const cache = createMediaAttachmentCache(attachments, {
+ localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx }),
+ });
try {
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
diff --git a/src/media-understanding/attachments.ts b/src/media-understanding/attachments.ts
index 14952b5319..62d7862734 100644
--- a/src/media-understanding/attachments.ts
+++ b/src/media-understanding/attachments.ts
@@ -7,6 +7,12 @@ import type { MediaAttachment, MediaUnderstandingCapability } from "./types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { isAbortError } from "../infra/unhandled-rejections.js";
import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
+import {
+ DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+ isInboundPathAllowed,
+ mergeInboundPathRoots,
+} from "../media/inbound-path-policy.js";
+import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { detectMime, getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js";
import { MediaUnderstandingSkipError } from "./errors.js";
@@ -36,6 +42,14 @@ type AttachmentCacheEntry = {
};
const DEFAULT_MAX_ATTACHMENTS = 1;
+const DEFAULT_LOCAL_PATH_ROOTS = mergeInboundPathRoots(
+ getDefaultMediaLocalRoots(),
+ DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+);
+
+export type MediaAttachmentCacheOptions = {
+ localPathRoots?: readonly string[];
+};
function normalizeAttachmentPath(raw?: string | null): string | undefined {
const value = raw?.trim();
@@ -209,9 +223,12 @@ export function selectAttachments(params: {
export class MediaAttachmentCache {
private readonly entries = new Map();
private readonly attachments: MediaAttachment[];
+ private readonly localPathRoots: readonly string[];
+ private canonicalLocalPathRoots?: Promise;
- constructor(attachments: MediaAttachment[]) {
+ constructor(attachments: MediaAttachment[], options?: MediaAttachmentCacheOptions) {
this.attachments = attachments;
+ this.localPathRoots = mergeInboundPathRoots(options?.localPathRoots, DEFAULT_LOCAL_PATH_ROOTS);
for (const attachment of attachments) {
this.entries.set(attachment.index, { attachment });
}
@@ -405,15 +422,37 @@ export class MediaAttachmentCache {
if (!entry.resolvedPath) {
return undefined;
}
+ if (!isInboundPathAllowed({ filePath: entry.resolvedPath, roots: this.localPathRoots })) {
+ entry.resolvedPath = undefined;
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `Blocked attachment path outside allowed roots: ${entry.attachment.path ?? entry.attachment.url ?? "(unknown)"}`,
+ );
+ }
+ return undefined;
+ }
if (entry.statSize !== undefined) {
return entry.statSize;
}
try {
- const stat = await fs.stat(entry.resolvedPath);
+ const currentPath = entry.resolvedPath;
+ const stat = await fs.stat(currentPath);
if (!stat.isFile()) {
entry.resolvedPath = undefined;
return undefined;
}
+ const canonicalPath = await fs.realpath(currentPath).catch(() => currentPath);
+ const canonicalRoots = await this.getCanonicalLocalPathRoots();
+ if (!isInboundPathAllowed({ filePath: canonicalPath, roots: canonicalRoots })) {
+ entry.resolvedPath = undefined;
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `Blocked canonicalized attachment path outside allowed roots: ${canonicalPath}`,
+ );
+ }
+ return undefined;
+ }
+ entry.resolvedPath = canonicalPath;
entry.statSize = stat.size;
return stat.size;
} catch (err) {
@@ -424,4 +463,23 @@ export class MediaAttachmentCache {
return undefined;
}
}
+
+ private async getCanonicalLocalPathRoots(): Promise {
+ if (this.canonicalLocalPathRoots) {
+ return await this.canonicalLocalPathRoots;
+ }
+ this.canonicalLocalPathRoots = (async () =>
+ mergeInboundPathRoots(
+ this.localPathRoots,
+ await Promise.all(
+ this.localPathRoots.map(async (root) => {
+ if (root.includes("*")) {
+ return root;
+ }
+ return await fs.realpath(root).catch(() => root);
+ }),
+ ),
+ ))();
+ return await this.canonicalLocalPathRoots;
+ }
}
diff --git a/src/media-understanding/audio-preflight.ts b/src/media-understanding/audio-preflight.ts
index 2dc5157e7c..8d730eeaf7 100644
--- a/src/media-understanding/audio-preflight.ts
+++ b/src/media-understanding/audio-preflight.ts
@@ -1,5 +1,6 @@
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/config.js";
+import type { MediaUnderstandingProvider } from "./types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { isAudioAttachment } from "./attachments.js";
import {
@@ -7,9 +8,9 @@ import {
buildProviderRegistry,
createMediaAttachmentCache,
normalizeMediaAttachments,
+ resolveMediaAttachmentLocalRoots,
runCapability,
} from "./runner.js";
-import type { MediaUnderstandingProvider } from "./types.js";
/**
* Transcribes the first audio attachment BEFORE mention checking.
@@ -50,7 +51,9 @@ export async function transcribeFirstAudio(params: {
}
const providerRegistry = buildProviderRegistry(params.providers);
- const cache = createMediaAttachmentCache(attachments);
+ const cache = createMediaAttachmentCache(attachments, {
+ localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx }),
+ });
try {
const result = await runCapability({
diff --git a/src/media-understanding/media-understanding-misc.test.ts b/src/media-understanding/media-understanding-misc.test.ts
index b48b9a2117..32e38577b5 100644
--- a/src/media-understanding/media-understanding-misc.test.ts
+++ b/src/media-understanding/media-understanding-misc.test.ts
@@ -1,3 +1,6 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { MediaAttachmentCache } from "./attachments.js";
@@ -39,4 +42,60 @@ describe("media understanding attachments SSRF", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
+
+ it("reads local attachments inside configured roots", async () => {
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cache-allowed-"));
+ try {
+ const allowedRoot = path.join(base, "allowed");
+ const attachmentPath = path.join(allowedRoot, "voice-note.m4a");
+ await fs.mkdir(allowedRoot, { recursive: true });
+ await fs.writeFile(attachmentPath, "ok");
+
+ const cache = new MediaAttachmentCache([{ index: 0, path: attachmentPath }], {
+ localPathRoots: [allowedRoot],
+ });
+
+ const result = await cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 });
+ expect(result.buffer.toString()).toBe("ok");
+ } finally {
+ await fs.rm(base, { recursive: true, force: true });
+ }
+ });
+
+ it("blocks local attachments outside configured roots", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ const cache = new MediaAttachmentCache([{ index: 0, path: "/etc/passwd" }], {
+ localPathRoots: ["/Users/*/Library/Messages/Attachments"],
+ });
+
+ await expect(
+ cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
+ ).rejects.toThrow(/has no path or URL/i);
+ });
+
+ it("blocks symlink escapes that resolve outside configured roots", async () => {
+ if (process.platform === "win32") {
+ return;
+ }
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cache-symlink-"));
+ try {
+ const allowedRoot = path.join(base, "allowed");
+ const outsidePath = "/etc/passwd";
+ const symlinkPath = path.join(allowedRoot, "note.txt");
+ await fs.mkdir(allowedRoot, { recursive: true });
+ await fs.symlink(outsidePath, symlinkPath);
+
+ const cache = new MediaAttachmentCache([{ index: 0, path: symlinkPath }], {
+ localPathRoots: [allowedRoot],
+ });
+
+ await expect(
+ cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
+ ).rejects.toThrow(/has no path or URL/i);
+ } finally {
+ await fs.rm(base, { recursive: true, force: true });
+ }
+ });
});
diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts
index bbe23a7b00..48f781e3c8 100644
--- a/src/media-understanding/runner.ts
+++ b/src/media-understanding/runner.ts
@@ -2,21 +2,39 @@ import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { resolveApiKeyForProvider } from "../agents/model-auth.js";
-import {
- findModelInCatalog,
- loadModelCatalog,
- modelSupportsVision,
-} from "../agents/model-catalog.js";
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
MediaUnderstandingConfig,
MediaUnderstandingModelConfig,
} from "../config/types.tools.js";
+import type {
+ MediaAttachment,
+ MediaUnderstandingCapability,
+ MediaUnderstandingDecision,
+ MediaUnderstandingModelDecision,
+ MediaUnderstandingOutput,
+ MediaUnderstandingProvider,
+} from "./types.js";
+import { resolveApiKeyForProvider } from "../agents/model-auth.js";
+import {
+ findModelInCatalog,
+ loadModelCatalog,
+ modelSupportsVision,
+} from "../agents/model-catalog.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
+import {
+ mergeInboundPathRoots,
+ resolveIMessageAttachmentRoots,
+} from "../media/inbound-path-policy.js";
+import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { runExec } from "../process/exec.js";
-import { MediaAttachmentCache, normalizeAttachments, selectAttachments } from "./attachments.js";
+import {
+ MediaAttachmentCache,
+ type MediaAttachmentCacheOptions,
+ normalizeAttachments,
+ selectAttachments,
+} from "./attachments.js";
import {
AUTO_AUDIO_KEY_PROVIDERS,
AUTO_IMAGE_KEY_PROVIDERS,
@@ -38,14 +56,6 @@ import {
runCliEntry,
runProviderEntry,
} from "./runner.entries.js";
-import type {
- MediaAttachment,
- MediaUnderstandingCapability,
- MediaUnderstandingDecision,
- MediaUnderstandingModelDecision,
- MediaUnderstandingOutput,
- MediaUnderstandingProvider,
-} from "./types.js";
export type ActiveMediaModel = {
provider: string;
@@ -69,8 +79,24 @@ export function normalizeMediaAttachments(ctx: MsgContext): MediaAttachment[] {
return normalizeAttachments(ctx);
}
-export function createMediaAttachmentCache(attachments: MediaAttachment[]): MediaAttachmentCache {
- return new MediaAttachmentCache(attachments);
+export function resolveMediaAttachmentLocalRoots(params: {
+ cfg: OpenClawConfig;
+ ctx: MsgContext;
+}): readonly string[] {
+ return mergeInboundPathRoots(
+ getDefaultMediaLocalRoots(),
+ resolveIMessageAttachmentRoots({
+ cfg: params.cfg,
+ accountId: params.ctx.AccountId,
+ }),
+ );
+}
+
+export function createMediaAttachmentCache(
+ attachments: MediaAttachment[],
+ options?: MediaAttachmentCacheOptions,
+): MediaAttachmentCache {
+ return new MediaAttachmentCache(attachments, options);
}
const binaryCache = new Map>();
diff --git a/src/media/inbound-path-policy.test.ts b/src/media/inbound-path-policy.test.ts
new file mode 100644
index 0000000000..3d7d300ec4
--- /dev/null
+++ b/src/media/inbound-path-policy.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import type { OpenClawConfig } from "../config/config.js";
+import {
+ DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+ isInboundPathAllowed,
+ isValidInboundPathRootPattern,
+ mergeInboundPathRoots,
+ resolveIMessageAttachmentRoots,
+ resolveIMessageRemoteAttachmentRoots,
+} from "./inbound-path-policy.js";
+
+describe("inbound-path-policy", () => {
+ it("validates absolute root patterns", () => {
+ expect(isValidInboundPathRootPattern("/Users/*/Library/Messages/Attachments")).toBe(true);
+ expect(isValidInboundPathRootPattern("/Volumes/relay/attachments")).toBe(true);
+ expect(isValidInboundPathRootPattern("./attachments")).toBe(false);
+ expect(isValidInboundPathRootPattern("/Users/**/Attachments")).toBe(false);
+ });
+
+ it("matches wildcard roots for iMessage attachment paths", () => {
+ const roots = ["/Users/*/Library/Messages/Attachments"];
+ expect(
+ isInboundPathAllowed({
+ filePath: "/Users/alice/Library/Messages/Attachments/12/34/ABCDEF/IMG_0001.jpeg",
+ roots,
+ }),
+ ).toBe(true);
+ expect(
+ isInboundPathAllowed({
+ filePath: "/etc/passwd",
+ roots,
+ }),
+ ).toBe(false);
+ });
+
+ it("normalizes and de-duplicates merged roots", () => {
+ const roots = mergeInboundPathRoots(
+ ["/Users/*/Library/Messages/Attachments/", "/Users/*/Library/Messages/Attachments"],
+ ["/Volumes/relay/attachments"],
+ );
+ expect(roots).toEqual(["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"]);
+ });
+
+ it("resolves configured roots with account overrides", () => {
+ const cfg = {
+ channels: {
+ imessage: {
+ attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
+ remoteAttachmentRoots: ["/Volumes/shared/imessage"],
+ accounts: {
+ work: {
+ attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
+ remoteAttachmentRoots: ["/srv/work/attachments"],
+ },
+ },
+ },
+ },
+ } as OpenClawConfig;
+ expect(resolveIMessageAttachmentRoots({ cfg, accountId: "work" })).toEqual([
+ "/Users/work/Library/Messages/Attachments",
+ "/Users/*/Library/Messages/Attachments",
+ ]);
+ expect(resolveIMessageRemoteAttachmentRoots({ cfg, accountId: "work" })).toEqual([
+ "/srv/work/attachments",
+ "/Volumes/shared/imessage",
+ "/Users/work/Library/Messages/Attachments",
+ "/Users/*/Library/Messages/Attachments",
+ ]);
+ });
+
+ it("falls back to default iMessage roots", () => {
+ const cfg = {} as OpenClawConfig;
+ expect(resolveIMessageAttachmentRoots({ cfg })).toEqual([...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS]);
+ expect(resolveIMessageRemoteAttachmentRoots({ cfg })).toEqual([
+ ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+ ]);
+ });
+});
diff --git a/src/media/inbound-path-policy.ts b/src/media/inbound-path-policy.ts
new file mode 100644
index 0000000000..7b98ece42f
--- /dev/null
+++ b/src/media/inbound-path-policy.ts
@@ -0,0 +1,150 @@
+import path from "node:path";
+import type { OpenClawConfig } from "../config/config.js";
+
+const WILDCARD_SEGMENT = "*";
+const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//;
+const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/;
+
+export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
+
+function normalizePosixAbsolutePath(value: string): string | undefined {
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.includes("\0")) {
+ return undefined;
+ }
+ const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/"));
+ const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized);
+ if (!isAbsolute || normalized === "/") {
+ return undefined;
+ }
+ const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
+ if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) {
+ return undefined;
+ }
+ return withoutTrailingSlash;
+}
+
+function splitPathSegments(value: string): string[] {
+ return value.split("/").filter(Boolean);
+}
+
+function matchesRootPattern(params: { candidatePath: string; rootPattern: string }): boolean {
+ const candidateSegments = splitPathSegments(params.candidatePath);
+ const rootSegments = splitPathSegments(params.rootPattern);
+ if (candidateSegments.length < rootSegments.length) {
+ return false;
+ }
+ for (let idx = 0; idx < rootSegments.length; idx += 1) {
+ const expected = rootSegments[idx];
+ const actual = candidateSegments[idx];
+ if (expected === WILDCARD_SEGMENT) {
+ continue;
+ }
+ if (expected !== actual) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function isValidInboundPathRootPattern(value: string): boolean {
+ const normalized = normalizePosixAbsolutePath(value);
+ if (!normalized) {
+ return false;
+ }
+ const segments = splitPathSegments(normalized);
+ if (segments.length === 0) {
+ return false;
+ }
+ return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*"));
+}
+
+export function normalizeInboundPathRoots(roots?: readonly string[]): string[] {
+ const normalized: string[] = [];
+ const seen = new Set();
+ for (const root of roots ?? []) {
+ if (typeof root !== "string") {
+ continue;
+ }
+ if (!isValidInboundPathRootPattern(root)) {
+ continue;
+ }
+ const candidate = normalizePosixAbsolutePath(root);
+ if (!candidate || seen.has(candidate)) {
+ continue;
+ }
+ seen.add(candidate);
+ normalized.push(candidate);
+ }
+ return normalized;
+}
+
+export function mergeInboundPathRoots(
+ ...rootsLists: Array
+): string[] {
+ const merged: string[] = [];
+ const seen = new Set();
+ for (const roots of rootsLists) {
+ const normalized = normalizeInboundPathRoots(roots);
+ for (const root of normalized) {
+ if (seen.has(root)) {
+ continue;
+ }
+ seen.add(root);
+ merged.push(root);
+ }
+ }
+ return merged;
+}
+
+export function isInboundPathAllowed(params: {
+ filePath: string;
+ roots: readonly string[];
+ fallbackRoots?: readonly string[];
+}): boolean {
+ const candidatePath = normalizePosixAbsolutePath(params.filePath);
+ if (!candidatePath) {
+ return false;
+ }
+ const roots = normalizeInboundPathRoots(params.roots);
+ const effectiveRoots =
+ roots.length > 0 ? roots : normalizeInboundPathRoots(params.fallbackRoots ?? undefined);
+ if (effectiveRoots.length === 0) {
+ return false;
+ }
+ return effectiveRoots.some((rootPattern) => matchesRootPattern({ candidatePath, rootPattern }));
+}
+
+function resolveIMessageAccountConfig(params: { cfg: OpenClawConfig; accountId?: string | null }) {
+ const accountId = params.accountId?.trim();
+ if (!accountId) {
+ return undefined;
+ }
+ return params.cfg.channels?.imessage?.accounts?.[accountId];
+}
+
+export function resolveIMessageAttachmentRoots(params: {
+ cfg: OpenClawConfig;
+ accountId?: string | null;
+}): string[] {
+ const accountConfig = resolveIMessageAccountConfig(params);
+ return mergeInboundPathRoots(
+ accountConfig?.attachmentRoots,
+ params.cfg.channels?.imessage?.attachmentRoots,
+ DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+ );
+}
+
+export function resolveIMessageRemoteAttachmentRoots(params: {
+ cfg: OpenClawConfig;
+ accountId?: string | null;
+}): string[] {
+ const accountConfig = resolveIMessageAccountConfig(params);
+ return mergeInboundPathRoots(
+ accountConfig?.remoteAttachmentRoots,
+ params.cfg.channels?.imessage?.remoteAttachmentRoots,
+ accountConfig?.attachmentRoots,
+ params.cfg.channels?.imessage?.attachmentRoots,
+ DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
+ );
+}