fix: enforce inbound attachment root policy across pipelines

This commit is contained in:
Peter Steinberger
2026-02-19 14:15:34 +01:00
parent cfe8457a0f
commit 1316e57403
16 changed files with 555 additions and 37 deletions

View File

@@ -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`).
</Tab>
</Tabs>
@@ -233,7 +238,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
<Accordion title="Multi-account pattern">
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.
</Accordion>
</AccordionGroup>
@@ -244,6 +249,10 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
<Accordion title="Attachments and media">
- 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)
</Accordion>
@@ -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

View File

@@ -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:<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`.
<Accordion title="iMessage SSH wrapper example">

View File

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

View File

@@ -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<string>();
const staged = new Map<string, string>(); // 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,

View File

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

View File

@@ -50,6 +50,10 @@ export type IMessageAccountConfig = {
dms?: Record<string, DmConfig>;
/** 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). */

View File

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

View File

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

View File

@@ -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 ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
const placeholder = kind
? `<media:${kind}>`
: validAttachments.length
? "<media:attachment>"
: "";
const bodyText = messageText || placeholder;
const storeAllowFrom = await readChannelAllowFromStore("imessage").catch(() => []);

View File

@@ -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 () => {

View File

@@ -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<number, AttachmentCacheEntry>();
private readonly attachments: MediaAttachment[];
private readonly localPathRoots: readonly string[];
private canonicalLocalPathRoots?: Promise<readonly string[]>;
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<readonly string[]> {
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;
}
}

View File

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

View File

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

View File

@@ -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<string, Promise<string | null>>();

View File

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

View File

@@ -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<string>();
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<readonly string[] | undefined>
): string[] {
const merged: string[] = [];
const seen = new Set<string>();
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,
);
}