fix(plugins): prevent untrusted workspace plugins from hijacking bundled provider auth choices [AI] (#62368)

* fix: address issue

* fix: address review feedback

* docs(changelog): add onboarding auth-choice guard entry

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Pavan Kumar Gondhi
2026-04-08 23:08:14 +05:30
committed by GitHub
parent 2d0e25c23a
commit 2d97eae53e
11 changed files with 531 additions and 107 deletions

View File

@@ -101,4 +101,31 @@ describe("resolvePreferredProviderForAuthChoice", () => {
);
expect(resolvePluginProviders).not.toHaveBeenCalled();
});
it("passes untrusted-workspace filtering through setup-provider fallback lookup", async () => {
resolvePluginProviders.mockReturnValue([
{
id: "demo-provider",
label: "Demo Provider",
auth: [{ id: "api-key", label: "API key", kind: "api_key" }],
},
] as never);
resolveProviderPluginChoice.mockReturnValue({
provider: { id: "demo-provider" },
method: { id: "api-key" },
});
await expect(
resolvePreferredProviderForAuthChoice({
choice: "demo-provider",
includeUntrustedWorkspacePlugins: false,
}),
).resolves.toBe("demo-provider");
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
mode: "setup",
includeUntrustedWorkspacePlugins: false,
}),
);
});
});

View File

@@ -115,9 +115,6 @@ async function writeWorkspaceChoiceHijackPlugin(workspaceDir: string): Promise<v
choiceLabel: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
optionKey: "openaiApiKey",
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
},
],
configSchema: {

View File

@@ -6,6 +6,10 @@ const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () =>
vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({
resolvePreferredProviderForAuthChoice,
}));
const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined));
vi.mock("../../../plugins/provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoice,
}));
const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined));
const resolveProviderPluginChoice = vi.hoisted(() => vi.fn());
@@ -20,6 +24,11 @@ vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({
beforeEach(() => {
vi.clearAllMocks();
resolvePreferredProviderForAuthChoice.mockResolvedValue(undefined);
resolveManifestProviderAuthChoice.mockReturnValue(undefined);
resolveOwningPluginIdsForProvider.mockReturnValue(undefined as never);
resolveProviderPluginChoice.mockReturnValue(undefined);
resolvePluginProviders.mockReturnValue([] as never);
});
function createRuntime() {
@@ -61,6 +70,7 @@ describe("applyNonInteractivePluginProviderChoice", () => {
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["vllm"],
includeUntrustedWorkspacePlugins: false,
}),
);
expect(resolveProviderPluginChoice).toHaveBeenCalledOnce();
@@ -68,7 +78,78 @@ describe("applyNonInteractivePluginProviderChoice", () => {
expect(result).toEqual({ plugins: { allow: ["vllm"] } });
});
it("enables owning plugin ids when they differ from the provider id", async () => {
it("fails explicitly when a provider-plugin auth choice resolves to no trusted setup provider", async () => {
const runtime = createRuntime();
const result = await applyNonInteractivePluginProviderChoice({
nextConfig: { agents: { defaults: {} } } as OpenClawConfig,
authChoice: "provider-plugin:workspace-provider:api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: { agents: { defaults: {} } } as OpenClawConfig,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(result).toBeNull();
expect(resolvePreferredProviderForAuthChoice).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(
'Auth choice "provider-plugin:workspace-provider:api-key" was not matched to a trusted provider plugin.',
),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("fails explicitly when a non-prefixed auth choice resolves only with untrusted providers", async () => {
const runtime = createRuntime();
resolvePreferredProviderForAuthChoice.mockResolvedValue(undefined);
resolveManifestProviderAuthChoice.mockReturnValueOnce(undefined).mockReturnValueOnce({
pluginId: "workspace-provider",
providerId: "workspace-provider",
} as never);
const result = await applyNonInteractivePluginProviderChoice({
nextConfig: { agents: { defaults: {} } } as OpenClawConfig,
authChoice: "workspace-provider-api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: { agents: { defaults: {} } } as OpenClawConfig,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(result).toBeNull();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(
'Auth choice "workspace-provider-api-key" matched a provider plugin that is not trusted or enabled for setup.',
),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
includeUntrustedWorkspacePlugins: false,
}),
);
expect(resolveProviderPluginChoice).toHaveBeenCalledTimes(1);
expect(resolvePluginProviders).toHaveBeenCalledTimes(1);
expect(resolveManifestProviderAuthChoice).toHaveBeenCalledWith(
"workspace-provider-api-key",
expect.objectContaining({
includeUntrustedWorkspacePlugins: false,
}),
);
expect(resolveManifestProviderAuthChoice).toHaveBeenCalledWith(
"workspace-provider-api-key",
expect.objectContaining({
config: expect.objectContaining({ agents: { defaults: {} } }),
workspaceDir: expect.any(String),
includeUntrustedWorkspacePlugins: true,
}),
);
});
it("limits setup-provider resolution to owning plugin ids without pre-enabling them", async () => {
const runtime = createRuntime();
const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["demo-plugin"] } }));
resolveOwningPluginIdsForProvider.mockReturnValue(["demo-plugin"] as never);
@@ -92,16 +173,9 @@ describe("applyNonInteractivePluginProviderChoice", () => {
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["demo-provider", "demo-plugin"]),
entries: expect.objectContaining({
"demo-provider": expect.objectContaining({ enabled: true }),
"demo-plugin": expect.objectContaining({ enabled: true }),
}),
}),
}),
config: expect.objectContaining({ agents: { defaults: {} } }),
onlyPluginIds: ["demo-plugin"],
includeUntrustedWorkspacePlugins: false,
}),
);
expect(runNonInteractive).toHaveBeenCalledOnce();
@@ -128,5 +202,10 @@ describe("applyNonInteractivePluginProviderChoice", () => {
includeUntrustedWorkspacePlugins: false,
}),
);
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
includeUntrustedWorkspacePlugins: false,
}),
);
});
});

View File

@@ -8,6 +8,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { enablePluginInConfig } from "../../../plugins/enable.js";
import { resolvePreferredProviderForAuthChoice } from "../../../plugins/provider-auth-choice-preference.js";
import { resolveManifestProviderAuthChoice } from "../../../plugins/provider-auth-choices.js";
import type {
ProviderAuthOptionBag,
ProviderNonInteractiveApiKeyCredentialParams,
@@ -28,40 +29,6 @@ const loadAuthChoicePluginProvidersRuntime = createLazyRuntimeSurface(
({ authChoicePluginProvidersRuntime }) => authChoicePluginProvidersRuntime,
);
function buildIsolatedProviderResolutionConfig(
cfg: OpenClawConfig,
ids: Iterable<string | undefined>,
): OpenClawConfig {
const allow = new Set(cfg.plugins?.allow ?? []);
const entries = {
...cfg.plugins?.entries,
};
let changed = false;
for (const rawId of ids) {
const id = rawId?.trim();
if (!id) {
continue;
}
allow.add(id);
entries[id] = {
...cfg.plugins?.entries?.[id],
enabled: true,
};
changed = true;
}
if (!changed) {
return cfg;
}
return {
...cfg,
plugins: {
...cfg.plugins,
allow: Array.from(allow),
entries,
},
};
}
export async function applyNonInteractivePluginProviderChoice(params: {
nextConfig: OpenClawConfig;
authChoice: string;
@@ -101,20 +68,50 @@ export async function applyNonInteractivePluginProviderChoice(params: {
workspaceDir,
})
: undefined;
const resolutionConfig = buildIsolatedProviderResolutionConfig(params.nextConfig, [
preferredProviderId,
...(owningPluginIds ?? []),
]);
const providerChoice = resolveProviderPluginChoice({
providers: resolvePluginProviders({
config: resolutionConfig,
config: params.nextConfig,
workspaceDir,
onlyPluginIds: owningPluginIds,
mode: "setup",
includeUntrustedWorkspacePlugins: false,
}),
choice: params.authChoice,
});
if (!providerChoice) {
if (prefixedProviderId) {
params.runtime.error(
[
`Auth choice "${params.authChoice}" was not matched to a trusted provider plugin.`,
"If this provider comes from a workspace plugin, trust/allow it first and retry.",
].join("\n"),
);
params.runtime.exit(1);
return null;
}
// Keep mismatch diagnostics metadata-only so untrusted workspace plugins are not loaded.
const trustedManifestMatch = resolveManifestProviderAuthChoice(params.authChoice, {
config: params.nextConfig,
workspaceDir,
includeUntrustedWorkspacePlugins: false,
});
const untrustedOnlyManifestMatch =
!trustedManifestMatch &&
resolveManifestProviderAuthChoice(params.authChoice, {
config: params.nextConfig,
workspaceDir,
includeUntrustedWorkspacePlugins: true,
});
if (untrustedOnlyManifestMatch) {
params.runtime.error(
[
`Auth choice "${params.authChoice}" matched a provider plugin that is not trusted or enabled for setup.`,
"If this provider comes from a workspace plugin, trust/allow it first and retry.",
].join("\n"),
);
params.runtime.exit(1);
return null;
}
return undefined;
}