diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index e793ef41071f..2d5d8a02a0d6 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -295,6 +295,25 @@ Read the requested file and summarize it. ).toBe(false); }); + it("surfaces manifest_parse_error finding when plugin package.json exceeds the size limit", async () => { + const tmpDir = await makeTmpDir("audit-manifest-oversized"); + const pluginDir = path.join(tmpDir, "extensions", "oversized-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + // Oversized manifest — simulates a plugin trying to exhaust the audit reader + // by declaring a huge package.json, hiding its declared extension entrypoints. + await fs.writeFile(path.join(pluginDir, "package.json"), "x".repeat(1024 * 1024 + 1), "utf-8"); + + const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); + const finding = requireFinding( + findings, + (f) => f.checkId === "plugins.code_safety.manifest_parse_error", + "oversized manifest parse error", + ); + expect(finding.severity).toBe("warn"); + expect(finding.detail).toContain("oversized-plugin"); + expect(finding.detail).toContain("too large"); + }); + it("reports scan_failed when plugin code scanner throws during deep audit", async () => { const scanSpy = vi .spyOn(skillScanner, "scanDirectoryWithSummary") diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 47251766f414..ac78390dbc78 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -3,7 +3,6 @@ * * These functions perform I/O (filesystem, config reads) to detect security issues. */ -import fs from "node:fs/promises"; import path from "node:path"; import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers"; import { @@ -21,6 +20,7 @@ import { MANIFEST_KEY } from "../compat/legacy-names.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; +import { readRegularFile, statRegularFile } from "../infra/fs-safe.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js"; @@ -97,9 +97,25 @@ function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null { return null; } +const MAX_PLUGIN_MANIFEST_BYTES = 1024 * 1024; + async function readPluginManifestExtensions(pluginPath: string): Promise { const manifestPath = path.join(pluginPath, "package.json"); - const raw = await fs.readFile(manifestPath, "utf-8").catch(() => ""); + const statResult = await statRegularFile(manifestPath); + if (statResult.missing) { + return []; + } + if (statResult.stat.size > MAX_PLUGIN_MANIFEST_BYTES) { + throw new Error( + `Plugin manifest at ${manifestPath} is too large (${statResult.stat.size} bytes, max ${MAX_PLUGIN_MANIFEST_BYTES})`, + ); + } + + const { buffer } = await readRegularFile({ + filePath: manifestPath, + maxBytes: MAX_PLUGIN_MANIFEST_BYTES, + }); + const raw = buffer.toString("utf-8"); if (!raw.trim()) { return []; }