mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 16:41:49 +00:00
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:
committed by
GitHub
parent
2d0e25c23a
commit
2d97eae53e
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user