diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f4354995f..3eb29e1b79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. + +- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. + ## 2026.3.12 ### Changes diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 0fa76f391dc..442d2cad96b 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -625,6 +625,75 @@ describe("hardenApprovedExecutionPaths", () => { }); }); + it("rejects perl module preloads that approval cannot bind completely", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-module-preload-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-MPreload", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects perl load-path flags that can redirect module resolution after approval", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-load-path-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-Ilib", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects perl combined preload and load-path flags", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-preload-load-path-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-Ilib", "-MPreload", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + it("rejects shell payloads that hide mutable interpreter scripts", () => { withFakeRuntimeBin({ binName: "node", diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 3fe37676776..6d90c8a7eb6 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -135,6 +135,7 @@ const NODE_OPTIONS_WITH_FILE_VALUE = new Set([ ]); const RUBY_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-r", "--require"]); +const PERL_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-M", "-m"]); const POSIX_SHELL_OPTIONS_WITH_VALUE = new Set([ "--init-file", @@ -668,6 +669,33 @@ function hasRubyUnsafeApprovalFlag(argv: string[]): boolean { return false; } +function hasPerlUnsafeApprovalFlag(argv: string[]): boolean { + let afterDoubleDash = false; + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return false; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-I" || token === "-M" || token === "-m") { + return true; + } + if (token.startsWith("-I") || token.startsWith("-M") || token.startsWith("-m")) { + return true; + } + if (PERL_UNSAFE_APPROVAL_FLAGS.has(token)) { + return true; + } + } + return false; +} + function isMutableScriptRunner(executable: string): boolean { return GENERIC_MUTABLE_SCRIPT_RUNNERS.has(executable) || isInterpreterLikeSafeBin(executable); } @@ -709,6 +737,9 @@ function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined) if (executable === "ruby" && hasRubyUnsafeApprovalFlag(unwrapped.argv)) { return null; } + if (executable === "perl" && hasPerlUnsafeApprovalFlag(unwrapped.argv)) { + return null; + } if (!isMutableScriptRunner(executable)) { return null; }