Files
openclaw/src/hooks/loader.ts
Vincent Koc d4f535b203 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
2026-03-12 20:47:30 -04:00

257 lines
8.5 KiB
TypeScript

/**
* Dynamic loader for hook handlers
*
* Loads hook handlers from external modules based on configuration
* and from directory-based discovery (bundled, managed, workspace)
*/
import fs from "node:fs";
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";
import type { InternalHookHandler } from "./internal-hooks.js";
import { registerInternalHook } from "./internal-hooks.js";
import { resolveFunctionModuleExport } from "./module-loader.js";
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
*
* Loads hooks from both:
* 1. Directory-based discovery (bundled, managed, workspace)
* 2. Legacy config handlers (backwards compatibility)
*
* @param cfg - OpenClaw configuration
* @param workspaceDir - Workspace directory for hook discovery
* @returns Number of handlers successfully loaded
*
* @example
* ```ts
* const config = await loadConfig();
* const workspaceDir = resolveAgentWorkspaceDir(config, agentId);
* const count = await loadInternalHooks(config, workspaceDir);
* console.log(`Loaded ${count} hook handlers`);
* ```
*/
export async function loadInternalHooks(
cfg: OpenClawConfig,
workspaceDir: string,
opts?: {
managedHooksDir?: string;
bundledHooksDir?: string;
},
): Promise<number> {
// Check if hooks are enabled
if (!cfg.hooks?.internal?.enabled) {
return 0;
}
let loadedCount = 0;
// 1. Load hooks from directories (new system)
try {
const hookEntries = loadWorkspaceHookEntries(workspaceDir, {
config: cfg,
managedHooksDir: opts?.managedHooksDir,
bundledHooksDir: opts?.bundledHooksDir,
});
// Filter by eligibility
const eligible = hookEntries.filter((entry) => shouldIncludeHook({ entry, config: cfg }));
for (const entry of eligible) {
const hookConfig = resolveHookConfig(cfg, entry.hook.name);
// Skip if explicitly disabled in config
if (hookConfig?.enabled === false) {
continue;
}
try {
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,
boundaryLabel: "hook directory",
});
if (!opened.ok) {
log.error(
`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);
const mod = (await import(importUrl)) as Record<string, unknown>;
// Get handler function (default or named export)
const exportName = entry.metadata?.export ?? "default";
const handler = resolveFunctionModuleExport<InternalHookHandler>({
mod,
exportName,
});
if (!handler) {
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 '${safeLogValue(entry.hook.name)}' has no events defined in metadata`);
continue;
}
for (const event of events) {
registerInternalHook(event, handler);
}
log.info(
`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 ${safeLogValue(entry.hook.name)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
}
} catch (err) {
log.error(
`Failed to load directory-based hooks: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
// 2. Load legacy config handlers (backwards compatibility)
const handlers = cfg.hooks.internal.handlers ?? [];
for (const handlerConfig of handlers) {
try {
// Legacy handler paths: keep them workspace-relative.
const rawModule = handlerConfig.module.trim();
if (!rawModule) {
log.error("Handler module path is empty");
continue;
}
if (path.isAbsolute(rawModule)) {
log.error(
`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 = 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: ${safeLogValue(rawModule)}`);
continue;
}
const opened = await openBoundaryFile({
absolutePath: modulePathSafe,
rootPath: baseDirReal,
boundaryLabel: "workspace directory",
});
if (!opened.ok) {
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");
const mod = (await import(importUrl)) as Record<string, unknown>;
// Get the handler function
const exportName = handlerConfig.export ?? "default";
const handler = resolveFunctionModuleExport<InternalHookHandler>({
mod,
exportName,
});
if (!handler) {
log.error(
`Handler '${safeLogValue(exportName)}' from ${safeLogValue(modulePath)} is not a function`,
);
continue;
}
registerInternalHook(handlerConfig.event, handler);
log.info(
`Registered hook (legacy): ${safeLogValue(handlerConfig.event)} -> ${safeLogValue(modulePath)}${exportName !== "default" ? `#${safeLogValue(exportName)}` : ""}`,
);
loadedCount++;
} catch (err) {
log.error(
`Failed to load hook handler from ${safeLogValue(handlerConfig.module)}: ${safeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
}
return loadedCount;
}
function resolveExistingRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}