diff --git a/CHANGELOG.md b/CHANGELOG.md index 8660c88ce0..45d541b004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6. - CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths. - Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao. +- Security/Plugins: install plugin and hook dependencies with `--ignore-scripts` to prevent lifecycle script execution. - Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc. - Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight. - Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo. diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index d9cc3b16aa..27a5616be2 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -4,10 +4,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import * as tar from "tar"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const tempDirs: string[] = []; +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), +})); + function makeTempDir() { const dir = path.join(os.tmpdir(), `openclaw-hook-install-${randomUUID()}`); fs.mkdirSync(dir, { recursive: true }); @@ -214,6 +218,67 @@ describe("installHooksFromArchive", () => { }); }); +describe("installHooksFromPath", () => { + it("uses --ignore-scripts for dependency install", async () => { + const workDir = makeTempDir(); + const stateDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "hooks", "one-hook"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@openclaw/test-hooks", + version: "0.0.1", + openclaw: { hooks: ["./hooks/one-hook"] }, + dependencies: { "left-pad": "1.3.0" }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "hooks", "one-hook", "HOOK.md"), + [ + "---", + "name: one-hook", + "description: One hook", + 'metadata: {"openclaw":{"events":["command:new"]}}', + "---", + "", + "# One Hook", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "hooks", "one-hook", "handler.ts"), + "export default async () => {};\n", + "utf-8", + ); + + const { runCommandWithTimeout } = await import("../process/exec.js"); + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + + const { installHooksFromPath } = await import("./install.js"); + const res = await installHooksFromPath({ + path: pkgDir, + hooksDir: path.join(stateDir, "hooks"), + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + + const calls = run.mock.calls.filter((c) => Array.isArray(c[0]) && c[0][0] === "npm"); + expect(calls.length).toBe(1); + const first = calls[0]; + if (!first) { + throw new Error("expected npm install call"); + } + const [argv, opts] = first; + expect(argv).toEqual(["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"]); + expect(opts?.cwd).toBe(res.targetDir); + }); +}); + describe("installHooksFromPath", () => { it("installs a single hook directory", async () => { const stateDir = makeTempDir(); diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 63e4be39e9..1d3dbe8c6c 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -234,10 +234,13 @@ async function installHookPackageFromDir(params: { const hasDeps = Object.keys(deps).length > 0; if (hasDeps) { logger.info?.("Installing hook pack dependencies…"); - const npmRes = await runCommandWithTimeout(["npm", "install", "--omit=dev", "--silent"], { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: targetDir, - }); + const npmRes = await runCommandWithTimeout( + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + { + timeoutMs: Math.max(timeoutMs, 300_000), + cwd: targetDir, + }, + ); if (npmRes.code !== 0) { if (backupDir) { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 2df77ded6b..9ed17f2743 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -6,6 +6,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), +})); + const tempDirs: string[] = []; function makeTempDir() { @@ -493,3 +497,47 @@ describe("installPluginFromArchive", () => { vi.resetModules(); }); }); + +describe("installPluginFromDir", () => { + it("uses --ignore-scripts for dependency install", async () => { + const workDir = makeTempDir(); + const stateDir = makeTempDir(); + const pluginDir = path.join(workDir, "plugin"); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/test-plugin", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + dependencies: { "left-pad": "1.3.0" }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + + const { runCommandWithTimeout } = await import("../process/exec.js"); + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ code: 0, stdout: "", stderr: "" }); + + const { installPluginFromDir } = await import("./install.js"); + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir: path.join(stateDir, "extensions"), + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + + const calls = run.mock.calls.filter((c) => Array.isArray(c[0]) && c[0][0] === "npm"); + expect(calls.length).toBe(1); + const first = calls[0]; + if (!first) { + throw new Error("expected npm install call"); + } + const [argv, opts] = first; + expect(argv).toEqual(["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"]); + expect(opts?.cwd).toBe(res.targetDir); + }); +}); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index bb8140629a..761d5fa6a4 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -278,10 +278,13 @@ async function installPluginFromPackageDir(params: { const hasDeps = Object.keys(deps).length > 0; if (hasDeps) { logger.info?.("Installing plugin dependencies…"); - const npmRes = await runCommandWithTimeout(["npm", "install", "--omit=dev", "--silent"], { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: targetDir, - }); + const npmRes = await runCommandWithTimeout( + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + { + timeoutMs: Math.max(timeoutMs, 300_000), + cwd: targetDir, + }, + ); if (npmRes.code !== 0) { if (backupDir) { await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);