Skip to content

Commit 24e584b

Browse files
committed
fix: derive plugin media trust from metadata
1 parent 5d01803 commit 24e584b

13 files changed

Lines changed: 179 additions & 47 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
### Fixes
1616

1717
- Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.
18+
- Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong.
1819
- Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz.
1920
- Gateway: clear the runtime config snapshot before `SIGUSR1` in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter.
2021
- Models: show OAuth delegation markers as configured `models.json` auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ import {
124124
} from "../../pi-embedded-helpers.js";
125125
import { countActiveToolExecutions } from "../../pi-embedded-subscribe.handlers.tools.js";
126126
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
127+
import { isCoreToolResultMediaTrustedName } from "../../pi-embedded-subscribe.tools.js";
127128
import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js";
128129
import {
129130
applyPiAutoCompactionGuard,
@@ -448,6 +449,35 @@ export {
448449
resolveEmbeddedAgentStreamFn,
449450
};
450451

452+
function collectTrustedPluginLocalMediaToolNames(params: {
453+
tools: Array<{ name?: string }>;
454+
}): Set<string> {
455+
const trusted = new Set<string>();
456+
for (const tool of params.tools) {
457+
const toolName = tool.name?.trim();
458+
if (!toolName) {
459+
continue;
460+
}
461+
const meta = getPluginToolMeta(tool as Parameters<typeof getPluginToolMeta>[0]);
462+
if (meta?.trustedLocalMedia === true) {
463+
trusted.add(toolName);
464+
}
465+
}
466+
return trusted;
467+
}
468+
469+
function collectTrustedLocalMediaToolNames(params: {
470+
coreBuiltinToolNames: ReadonlySet<string>;
471+
trustedPluginToolNames: ReadonlySet<string>;
472+
}): Set<string> {
473+
return new Set([
474+
...[...params.coreBuiltinToolNames].filter((toolName) =>
475+
isCoreToolResultMediaTrustedName(toolName),
476+
),
477+
...params.trustedPluginToolNames,
478+
]);
479+
}
480+
451481
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
452482
const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [
453483
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
@@ -1651,6 +1681,11 @@ export async function runEmbeddedAttempt(
16511681
modelApi: params.model.api,
16521682
model: params.model,
16531683
};
1684+
const pluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({
1685+
config: params.config,
1686+
env: process.env,
1687+
workspaceDir: effectiveWorkspace,
1688+
});
16541689
const tools = normalizeAgentRuntimeTools({
16551690
runtimePlan: params.runtimePlan,
16561691
tools: toolsEnabled ? toolsRaw : [],
@@ -1721,6 +1756,9 @@ export async function runEmbeddedAttempt(
17211756
senderE164: params.senderE164,
17221757
warn: (message) => log.warn(message),
17231758
});
1759+
const trustedPluginLocalMediaToolNames = collectTrustedPluginLocalMediaToolNames({
1760+
tools: toolsEnabled ? [...toolsRaw, ...filteredBundledTools] : [],
1761+
});
17241762
const normalizedBundledTools =
17251763
filteredBundledTools.length > 0
17261764
? normalizeAgentRuntimeTools({
@@ -2202,11 +2240,7 @@ export async function runEmbeddedAttempt(
22022240
cwd: effectiveWorkspace,
22032241
agentDir,
22042242
cfg: params.config,
2205-
pluginMetadataSnapshot: getCurrentPluginMetadataSnapshot({
2206-
config: params.config,
2207-
env: process.env,
2208-
workspaceDir: effectiveWorkspace,
2209-
}),
2243+
pluginMetadataSnapshot,
22102244
contextTokenBudget: params.contextTokenBudget,
22112245
});
22122246
const piAutoCompactionGuardArgs = {
@@ -2285,10 +2319,8 @@ export async function runEmbeddedAttempt(
22852319
cfg: params.config,
22862320
agentId: sessionAgentId,
22872321
});
2288-
// Exact raw names of every tool registered for this run, including
2289-
// bundled/plugin tools. Used as the raw-name set for the trusted local
2290-
// MEDIA: passthrough gate: a normalized alias is not sufficient — the
2291-
// emitted tool name must match an exact registration of this run.
2322+
// Exact raw names of every tool registered for this run. This remains
2323+
// available for diagnostics; local MEDIA: trust is narrower below.
22922324
const builtinToolNames = new Set(
22932325
uncompactedEffectiveTools.flatMap((tool) => {
22942326
const name = (tool.name ?? "").trim();
@@ -2304,6 +2336,10 @@ export async function runEmbeddedAttempt(
23042336
isPluginTool: (tool) =>
23052337
Boolean(getPluginToolMeta(tool as Parameters<typeof getPluginToolMeta>[0])),
23062338
});
2339+
const trustedLocalMediaToolNames = collectTrustedLocalMediaToolNames({
2340+
coreBuiltinToolNames,
2341+
trustedPluginToolNames: trustedPluginLocalMediaToolNames,
2342+
});
23072343
const clientToolNameConflicts = findClientToolNameConflicts({
23082344
tools: clientTools ?? [],
23092345
existingToolNames: [...coreBuiltinToolNames, ...PI_RESERVED_TOOL_NAMES],
@@ -3303,6 +3339,7 @@ export async function runEmbeddedAttempt(
33033339
sessionId: params.sessionId,
33043340
agentId: sessionAgentId,
33053341
builtinToolNames,
3342+
trustedLocalMediaToolNames,
33063343
internalEvents: params.internalEvents,
33073344
}),
33083345
);

src/agents/pi-embedded-subscribe.e2e-harness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export function createSubscribedSessionHarness(
4545
const mergedSession = Object.assign(session, sessionExtras ?? {});
4646
const subscription = subscribeEmbeddedPiSession({
4747
...subscribeParams,
48+
trustedLocalMediaToolNames:
49+
subscribeParams.trustedLocalMediaToolNames ?? subscribeParams.builtinToolNames,
4850
session: mergedSession,
4951
});
5052
return { emit, session: mergedSession, subscription };

src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ function createMockContext(overrides?: {
1111
onToolResult?: ReturnType<typeof vi.fn>;
1212
toolResultFormat?: "markdown" | "plain";
1313
builtinToolNames?: ReadonlySet<string>;
14+
trustedLocalMediaToolNames?: ReadonlySet<string>;
1415
}): EmbeddedPiSubscribeContext {
1516
const onToolResult = overrides?.onToolResult ?? vi.fn();
1617
return {
@@ -44,6 +45,8 @@ function createMockContext(overrides?: {
4445
},
4546
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() },
4647
builtinToolNames: overrides?.builtinToolNames,
48+
trustedLocalMediaToolNames:
49+
overrides?.trustedLocalMediaToolNames ?? overrides?.builtinToolNames,
4750
shouldEmitToolResult: vi.fn(() => false),
4851
shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false),
4952
emitToolSummary: vi.fn(),
@@ -465,6 +468,37 @@ describe("handleToolExecutionEnd media emission", () => {
465468
expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]);
466469
});
467470

471+
it("does not queue trusted bundled plugin media already emitted in plain verbose output", async () => {
472+
const ctx = createMockContext({
473+
shouldEmitToolOutput: true,
474+
toolResultFormat: "plain",
475+
trustedLocalMediaToolNames: new Set(["meeting_notes"]),
476+
});
477+
478+
await handleToolExecutionEnd(ctx, {
479+
type: "tool_execution_end",
480+
toolName: "meeting_notes",
481+
toolCallId: "tc-1",
482+
isError: false,
483+
result: {
484+
content: [
485+
{
486+
type: "text",
487+
text: "Meeting audio attached.\nMEDIA:/tmp/meeting.wav",
488+
},
489+
],
490+
details: {
491+
media: {
492+
mediaUrls: ["/tmp/meeting.wav"],
493+
},
494+
},
495+
},
496+
});
497+
498+
expect(ctx.emitToolOutput).toHaveBeenCalledTimes(1);
499+
expect(ctx.state.pendingToolMediaUrls).toStrictEqual([]);
500+
});
501+
468502
it("queues structured media once for markdown verbose output", async () => {
469503
const ctx = await handleVerboseGeneratedImage("markdown");
470504

src/agents/pi-embedded-subscribe.handlers.tools.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,13 +537,14 @@ async function collectEmittedToolOutputMediaUrls(
537537
toolName: string,
538538
outputText: string,
539539
result: unknown,
540+
trustedLocalMediaToolNames?: ReadonlySet<string>,
540541
): Promise<string[]> {
541542
const { splitMediaFromOutput } = await loadMediaParse();
542543
const mediaUrls = splitMediaFromOutput(outputText).mediaUrls ?? [];
543544
if (mediaUrls.length === 0) {
544545
return [];
545546
}
546-
return filterToolResultMediaUrls(toolName, mediaUrls, result);
547+
return filterToolResultMediaUrls(toolName, mediaUrls, result, trustedLocalMediaToolNames);
547548
}
548549

549550
function readExecApprovalPendingDetails(result: unknown): {
@@ -712,7 +713,12 @@ async function emitToolResultOutput(params: {
712713
const outputText = extractToolResultText(sanitizedResult);
713714
const mediaReply = isToolError ? undefined : extractToolResultMediaArtifact(result);
714715
const mediaUrls = mediaReply
715-
? filterToolResultMediaUrls(rawToolName, mediaReply.mediaUrls, result, ctx.builtinToolNames)
716+
? filterToolResultMediaUrls(
717+
rawToolName,
718+
mediaReply.mediaUrls,
719+
result,
720+
ctx.trustedLocalMediaToolNames,
721+
)
716722
: [];
717723
const shouldEmitOutput =
718724
!shouldSuppressStructuredMediaToolOutput({
@@ -730,6 +736,7 @@ async function emitToolResultOutput(params: {
730736
rawToolName,
731737
outputText,
732738
result,
739+
ctx.trustedLocalMediaToolNames,
733740
);
734741
}
735742
}

src/agents/pi-embedded-subscribe.handlers.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export type EmbeddedPiSubscribeContext = {
132132
blockChunker: EmbeddedBlockChunker | null;
133133
hookRunner?: HookRunner;
134134
builtinToolNames?: ReadonlySet<string>;
135+
trustedLocalMediaToolNames?: ReadonlySet<string>;
135136
noteLastAssistant: (msg: AgentMessage) => void;
136137

137138
shouldEmitToolResult: () => boolean;
@@ -244,6 +245,7 @@ export type ToolHandlerContext = {
244245
log: EmbeddedSubscribeLogger;
245246
hookRunner?: HookRunner;
246247
builtinToolNames?: ReadonlySet<string>;
248+
trustedLocalMediaToolNames?: ReadonlySet<string>;
247249
flushBlockReplyBuffer: () => void | Promise<void>;
248250
shouldEmitToolResult: () => boolean;
249251
shouldEmitToolOutput: () => boolean;

src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("subscribeEmbeddedPiSession", () => {
5353
subscribeEmbeddedPiSession({
5454
session,
5555
...options,
56+
trustedLocalMediaToolNames: options.trustedLocalMediaToolNames ?? options.builtinToolNames,
5657
});
5758
return { emit };
5859
}

src/agents/pi-embedded-subscribe.tools.media.test.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,14 @@ describe("extractToolResultMediaPaths", () => {
340340
expect(isToolResultMediaTrusted("video_generate")).toBe(true);
341341
});
342342

343-
it("trusts bundled plugin tool local MEDIA paths", () => {
344-
expect(isToolResultMediaTrusted("music_generate")).toBe(true);
343+
it("does not trust bundled plugin tool names without run-local metadata", () => {
344+
expect(isToolResultMediaTrusted("meeting_notes")).toBe(false);
345+
});
346+
347+
it("trusts bundled plugin tool names carried by run-local metadata", () => {
348+
expect(isToolResultMediaTrusted("meeting_notes", undefined, new Set(["meeting_notes"]))).toBe(
349+
true,
350+
);
345351
});
346352

347353
it("blocks trusted-media aliases that are not exact registered built-ins", () => {
@@ -382,18 +388,15 @@ describe("extractToolResultMediaPaths", () => {
382388
).toEqual(["/tmp/reply.opus"]);
383389
});
384390

385-
it("keeps local media for bundled plugin tool names registered in this run", () => {
386-
// music_generate is a bundled-plugin trusted tool; when the runner
387-
// registers it for this run, its raw name must be allowed through the
388-
// exact-name gate just like a core built-in.
391+
it("keeps local media for bundled plugin tool names trusted in this run", () => {
389392
expect(
390393
filterToolResultMediaUrls(
391-
"music_generate",
392-
["/tmp/song.mp3"],
394+
"meeting_notes",
395+
["/tmp/meeting.wav"],
393396
undefined,
394-
new Set(["music_generate"]),
397+
new Set(["meeting_notes"]),
395398
),
396-
).toEqual(["/tmp/song.mp3"]);
399+
).toEqual(["/tmp/meeting.wav"]);
397400
});
398401

399402
it("strips local media for plugin-name collisions when the plugin is not registered", () => {

src/agents/pi-embedded-subscribe.tools.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.
22
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
33
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
44
import { splitMediaFromOutput } from "../media/parse.js";
5-
import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js";
65
import {
76
normalizeOptionalLowercaseString,
87
normalizeOptionalString,
@@ -277,8 +276,8 @@ export function extractToolResultText(result: unknown): string | undefined {
277276
return texts.join("\n");
278277
}
279278

280-
// Core tool names that are allowed to emit local MEDIA: paths.
281-
// Plugin/MCP tools are intentionally excluded to prevent untrusted file reads.
279+
// Core tool names that are allowed to emit local MEDIA: paths. Plugin tools
280+
// must be explicitly passed as trusted run-local names by the caller.
282281
const TRUSTED_TOOL_RESULT_MEDIA = new Set([
283282
"agents_list",
284283
"apply_patch",
@@ -310,11 +309,15 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([
310309
"x_search",
311310
"write",
312311
]);
313-
const TRUSTED_BUNDLED_PLUGIN_MEDIA_TOOLS = new Set(
314-
pluginRegistrationContractRegistry.flatMap((entry) => entry.toolNames),
315-
);
316312
const HTTP_URL_RE = /^https?:\/\//i;
317313

314+
export function isCoreToolResultMediaTrustedName(toolName?: string): boolean {
315+
if (!toolName) {
316+
return false;
317+
}
318+
return TRUSTED_TOOL_RESULT_MEDIA.has(normalizeToolName(toolName));
319+
}
320+
318321
function readToolResultDetails(result: unknown): Record<string, unknown> | undefined {
319322
if (!result || typeof result !== "object") {
320323
return undefined;
@@ -338,20 +341,29 @@ function isExternalToolResult(result: unknown): boolean {
338341
return typeof details.mcpServer === "string" || typeof details.mcpTool === "string";
339342
}
340343

341-
export function isToolResultMediaTrusted(toolName?: string, result?: unknown): boolean {
344+
export function isToolResultMediaTrusted(
345+
toolName?: string,
346+
result?: unknown,
347+
trustedLocalMediaToolNames?: ReadonlySet<string>,
348+
): boolean {
342349
if (!toolName || isExternalToolResult(result)) {
343350
return false;
344351
}
345-
const normalized = normalizeToolName(toolName);
346-
return (
347-
TRUSTED_TOOL_RESULT_MEDIA.has(normalized) || TRUSTED_BUNDLED_PLUGIN_MEDIA_TOOLS.has(normalized)
348-
);
352+
const registeredName = toolName.trim();
353+
if (registeredName && trustedLocalMediaToolNames?.has(registeredName) === true) {
354+
return true;
355+
}
356+
return isCoreToolResultMediaTrustedName(toolName);
349357
}
350358

351-
function isTrustedOwnedTtsLocalMedia(toolName: string | undefined, result: unknown): boolean {
359+
function isTrustedOwnedTtsLocalMedia(
360+
toolName: string | undefined,
361+
result: unknown,
362+
trustedLocalMediaToolNames?: ReadonlySet<string>,
363+
): boolean {
352364
if (
353365
!toolName ||
354-
!isToolResultMediaTrusted(toolName, result) ||
366+
!isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames) ||
355367
normalizeToolName(toolName) !== "tts"
356368
) {
357369
return false;
@@ -367,25 +379,29 @@ export function filterToolResultMediaUrls(
367379
toolName: string | undefined,
368380
mediaUrls: string[],
369381
result?: unknown,
370-
builtinToolNames?: ReadonlySet<string>,
382+
trustedLocalMediaToolNames?: ReadonlySet<string>,
371383
): string[] {
372384
if (mediaUrls.length === 0) {
373385
return mediaUrls;
374386
}
375-
const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia(toolName, result);
376-
if (isToolResultMediaTrusted(toolName, result)) {
377-
// When the current run provides its exact registered tool names (core
378-
// built-ins plus bundled/trusted plugin tools), require the raw emitted
379-
// tool name to match one of them before allowing local MEDIA: paths.
387+
const trustedOwnedTtsLocalMedia = isTrustedOwnedTtsLocalMedia(
388+
toolName,
389+
result,
390+
trustedLocalMediaToolNames,
391+
);
392+
if (isToolResultMediaTrusted(toolName, result, trustedLocalMediaToolNames)) {
393+
// When the current run provides its exact trusted local-media tool names,
394+
// require the raw emitted tool name to match one of them before allowing
395+
// local MEDIA: paths.
380396
// This blocks normalized aliases and case-variant collisions such as
381397
// "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a
382398
// registered tool's media trust. TTS-generated local files carry a
383399
// separate trusted-media flag from the owned tool result, so they can
384-
// survive runs whose exact built-in set omitted the raw tts name.
385-
if (builtinToolNames !== undefined) {
400+
// survive runs whose exact trusted set omitted the raw tts name.
401+
if (trustedLocalMediaToolNames !== undefined) {
386402
if (!trustedOwnedTtsLocalMedia) {
387403
const registeredName = toolName?.trim();
388-
if (!registeredName || !builtinToolNames.has(registeredName)) {
404+
if (!registeredName || !trustedLocalMediaToolNames.has(registeredName)) {
389405
return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
390406
}
391407
}

0 commit comments

Comments
 (0)