fix(hooks): fail closed on unreadable loader paths (#44437)

* Hooks: fail closed on unreadable loader paths

* Changelog: note hooks loader hardening

* Tests: cover sanitized hook loader logs

* Hooks: use realpath containment for legacy loaders

* Hooks: sanitize unreadable workspace log path
This commit is contained in:
Vincent Koc
2026-03-12 20:47:30 -04:00
committed by GitHub
parent 2649c03cdb
commit d4f535b203
3 changed files with 99 additions and 19 deletions

View File

@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup.
- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
- Hooks/loader: fail closed when workspace hook paths cannot be resolved with `realpath`, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
- Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.
- Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.
- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.

View File

@@ -1,8 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { captureEnv } from "../test-utils/env.js";
import {
clearInternalHooks,
@@ -31,6 +33,13 @@ describe("loader", () => {
// Disable bundled hooks during tests by setting env var to non-existent directory
envSnapshot = captureEnv(["OPENCLAW_BUNDLED_HOOKS_DIR"]);
process.env.OPENCLAW_BUNDLED_HOOKS_DIR = "/nonexistent/bundled/hooks";
setLoggerOverride({ level: "silent", consoleLevel: "error" });
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
async function writeHandlerModule(
@@ -54,6 +63,8 @@ describe("loader", () => {
afterEach(async () => {
clearInternalHooks();
loggingState.rawConsole = null;
setLoggerOverride(null);
envSnapshot.restore();
});
@@ -336,5 +347,26 @@ describe("loader", () => {
await expectNoCommandHookRegistration(createLegacyHandlerConfig());
});
it("sanitizes control characters in loader error logs", async () => {
const error = loggingState.rawConsole?.error;
expect(error).toBeTypeOf("function");
const cfg = createEnabledHooksConfig([
{
event: "command:new",
module: `${tmpDir}\u001b[31m\nforged-log`,
},
]);
await expectNoCommandHookRegistration(cfg);
const messages = (error as ReturnType<typeof vi.fn>).mock.calls
.map((call) => String(call[0] ?? ""))
.join("\n");
expect(messages).toContain("forged-log");
expect(messages).not.toContain("\u001b[31m");
expect(messages).not.toContain("\nforged-log");
});
});
});

View File

@@ -10,6 +10,7 @@ import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFile } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveHookConfig } from "./config.js";
import { shouldIncludeHook } from "./config.js";
import { buildImportUrl } from "./import-url.js";
@@ -20,6 +21,24 @@ import { loadWorkspaceHookEntries } from "./workspace.js";
const log = createSubsystemLogger("hooks:loader");
function safeLogValue(value: string): string {
return sanitizeForLog(value);
}
function maybeWarnTrustedHookSource(source: string): void {
if (source === "openclaw-workspace") {
log.warn(
"Loading workspace hook code into the gateway process. Workspace hooks are trusted local code.",
);
return;
}
if (source === "openclaw-managed") {
log.warn(
"Loading managed hook code into the gateway process. Managed hooks are trusted local code.",
);
}
}
/**
* Load and register all hook handlers
*
@@ -74,7 +93,13 @@ export async function loadInternalHooks(
}
try {
const hookBaseDir = safeRealpathOrResolve(entry.hook.baseDir);
const hookBaseDir = resolveExistingRealpath(entry.hook.baseDir);
if (!hookBaseDir) {
log.error(
`Hook '${safeLogValue(entry.hook.name)}' base directory is no longer readable: ${safeLogValue(entry.hook.baseDir)}`,
);
continue;
}
const opened = await openBoundaryFile({
absolutePath: entry.hook.handlerPath,
rootPath: hookBaseDir,
@@ -82,12 +107,13 @@ export async function loadInternalHooks(
});
if (!opened.ok) {
log.error(
`Hook '${entry.hook.name}' handler path fails boundary checks: ${entry.hook.handlerPath}`,
`Hook '${safeLogValue(entry.hook.name)}' handler path fails boundary checks: ${safeLogValue(entry.hook.handlerPath)}`,
);
continue;
}
const safeHandlerPath = opened.path;
fs.closeSync(opened.fd);
maybeWarnTrustedHookSource(entry.hook.source);
// Import handler module — only cache-bust mutable (workspace/managed) hooks
const importUrl = buildImportUrl(safeHandlerPath, entry.hook.source);
@@ -101,14 +127,16 @@ export async function loadInternalHooks(
});
if (!handler) {
log.error(`Handler '${exportName}' from ${entry.hook.name} is not a function`);
log.error(
`Handler '${safeLogValue(exportName)}' from ${safeLogValue(entry.hook.name)} is not a function`,
);
continue;
}
// Register for all events listed in metadata
const events = entry.metadata?.events ?? [];
if (events.length === 0) {
log.warn(`Hook '${entry.hook.name}' has no events defined in metadata`);
log.warn(`Hook '${safeLogValue(entry.hook.name)}' has no events defined in metadata`);
continue;
}
@@ -117,18 +145,18 @@ export async function loadInternalHooks(
}
log.info(
`Registered hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
`Registered hook: ${safeLogValue(entry.hook.name)} -> ${events.map((event) => safeLogValue(event)).join(", ")}${exportName !== "default" ? ` (export: ${safeLogValue(exportName)})` : ""}`,
);
loadedCount++;
} catch (err) {
log.error(
`Failed to load hook ${entry.hook.name}: ${err instanceof Error ? err.message : String(err)}`,
`Failed to load hook ${safeLogValue(entry.hook.name)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
}
} catch (err) {
log.error(
`Failed to load directory-based hooks: ${err instanceof Error ? err.message : String(err)}`,
`Failed to load directory-based hooks: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
@@ -144,17 +172,29 @@ export async function loadInternalHooks(
}
if (path.isAbsolute(rawModule)) {
log.error(
`Handler module path must be workspace-relative (got absolute path): ${rawModule}`,
`Handler module path must be workspace-relative (got absolute path): ${safeLogValue(rawModule)}`,
);
continue;
}
const baseDir = path.resolve(workspaceDir);
const modulePath = path.resolve(baseDir, rawModule);
const baseDirReal = safeRealpathOrResolve(baseDir);
const modulePathSafe = safeRealpathOrResolve(modulePath);
const rel = path.relative(baseDir, modulePath);
const baseDirReal = resolveExistingRealpath(baseDir);
if (!baseDirReal) {
log.error(
`Workspace directory is no longer readable while loading hooks: ${safeLogValue(baseDir)}`,
);
continue;
}
const modulePathSafe = resolveExistingRealpath(modulePath);
if (!modulePathSafe) {
log.error(
`Handler module path could not be resolved with realpath: ${safeLogValue(rawModule)}`,
);
continue;
}
const rel = path.relative(baseDirReal, modulePathSafe);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
log.error(`Handler module path must stay within workspaceDir: ${rawModule}`);
log.error(`Handler module path must stay within workspaceDir: ${safeLogValue(rawModule)}`);
continue;
}
const opened = await openBoundaryFile({
@@ -163,11 +203,16 @@ export async function loadInternalHooks(
boundaryLabel: "workspace directory",
});
if (!opened.ok) {
log.error(`Handler module path fails boundary checks under workspaceDir: ${rawModule}`);
log.error(
`Handler module path fails boundary checks under workspaceDir: ${safeLogValue(rawModule)}`,
);
continue;
}
const safeModulePath = opened.path;
fs.closeSync(opened.fd);
log.warn(
`Loading legacy internal hook module from workspace path ${safeLogValue(rawModule)}. Legacy hook modules are trusted local code.`,
);
// Legacy handlers are always workspace-relative, so use mtime-based cache busting
const importUrl = buildImportUrl(safeModulePath, "openclaw-workspace");
@@ -181,18 +226,20 @@ export async function loadInternalHooks(
});
if (!handler) {
log.error(`Handler '${exportName}' from ${modulePath} is not a function`);
log.error(
`Handler '${safeLogValue(exportName)}' from ${safeLogValue(modulePath)} is not a function`,
);
continue;
}
registerInternalHook(handlerConfig.event, handler);
log.info(
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
`Registered hook (legacy): ${safeLogValue(handlerConfig.event)} -> ${safeLogValue(modulePath)}${exportName !== "default" ? `#${safeLogValue(exportName)}` : ""}`,
);
loadedCount++;
} catch (err) {
log.error(
`Failed to load hook handler from ${handlerConfig.module}: ${err instanceof Error ? err.message : String(err)}`,
`Failed to load hook handler from ${safeLogValue(handlerConfig.module)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
}
@@ -200,10 +247,10 @@ export async function loadInternalHooks(
return loadedCount;
}
function safeRealpathOrResolve(value: string): string {
function resolveExistingRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
return null;
}
}