From 803b7ab8085d444d52d42a114490093eade0e00c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 09:48:22 +0100 Subject: [PATCH] fix: warn on invalid hook transform directories --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 1 + src/commands/doctor-config-flow.test.ts | 24 ++++++++++++++++++++ src/commands/doctor-config-flow.ts | 29 +++++++++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39371b40f34..452c87c669f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987. - MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar. - TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge. +- Hooks/doctor: warn when `hooks.transformsDir` points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk. - Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index d6157dfd626..a41eac28443 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -590,6 +590,7 @@ Validation and safety notes: - Templates like `{{messages[0].subject}}` read from the payload. - `transform` can point to a JS/TS module returning a hook action. - `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected). + - Keep `hooks.transformsDir` under `~/.openclaw/hooks/transforms`; workspace skill directories are rejected. If `openclaw doctor` reports this path as invalid, move the transform module into the hooks transforms directory or remove `hooks.transformsDir`. - `agentId` routes to a specific agent; unknown IDs fall back to default. - `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all). - `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`. diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 110d80b16ac..a284b82116a 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1385,6 +1385,30 @@ describe("doctor config flow", () => { expect(doctorWarnings.some((line) => line.includes("mutable allowlist"))).toBe(false); }); + it("warns when hooks transformsDir points outside the hook transforms root", async () => { + const doctorWarnings = await collectDoctorWarnings({ + hooks: { + enabled: true, + token: "hook-secret", + transformsDir: "/virtual/.openclaw/workspace/skills/linear-webhook", + mappings: [ + { + match: { path: "linear" }, + action: "agent", + messageTemplate: "Linear event", + transform: { module: "./openclaw-linear-transform.js" }, + }, + ], + }, + }); + + const warning = doctorWarnings.join("\n"); + expect(warning).toContain("hooks.transformsDir:"); + expect(warning).toContain("/virtual/.openclaw/workspace/skills/linear-webhook"); + expect(warning).toContain("/virtual/.openclaw/hooks/transforms"); + expect(warning).toContain("move custom transforms there or remove hooks.transformsDir"); + }); + it("does not warn about sender-based group allowlist for googlechat", async () => { const doctorWarnings = await collectDoctorWarnings({ channels: { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b54689f74c1..31e6c882055 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import { findLegacyConfigIssues } from "../config/legacy.js"; import { CONFIG_PATH } from "../config/paths.js"; @@ -26,6 +27,30 @@ function hasLegacyInternalHookHandlers(raw: unknown): boolean { return Array.isArray(handlers) && handlers.length > 0; } +function collectInvalidHookTransformsDirWarnings( + cfg: OpenClawConfig, + configPath: string, +): string[] { + const transformsDir = cfg.hooks?.transformsDir?.trim(); + if (!transformsDir) { + return []; + } + const configDir = path.dirname(configPath); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + const resolved = path.isAbsolute(transformsDir) + ? path.resolve(transformsDir) + : path.resolve(transformsRoot, transformsDir); + const relative = path.relative(transformsRoot, resolved); + const escapesRoot = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (!escapesRoot) { + return []; + } + return [ + `- hooks.transformsDir: ${transformsDir} is outside ${transformsRoot}. Hook transform modules must live under ${transformsRoot}; move custom transforms there or remove hooks.transformsDir.`, + ]; +} + function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { const channels = cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) @@ -111,6 +136,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { "Legacy config keys detected", ); } + const hookTransformsDirWarnings = collectInvalidHookTransformsDirWarnings(cfg, snapshot.path); + if (hookTransformsDirWarnings.length > 0) { + note(sanitizeDoctorNote(hookTransformsDirWarnings.join("\n")), "Doctor warnings"); + } const normalized = normalizeCompatibilityConfigValues(candidate); if (normalized.changes.length > 0) {