Preserve dynamic tool hooks in Codex mode#70965
Conversation
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
1. 🟠 Before-tool-call hook bypass via spoofable wrapped-marker check (Proxy tools)
Description
In JavaScript/TypeScript, an untrusted tool provider can supply a Vulnerable logic: const tools = params.tools.map((tool) =>
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, params.hookContext),
);Because RecommendationDo not rely on a property on the tool object to decide whether to enforce Prefer one of:
const tools = params.tools.map((tool) => wrapToolWithBeforeToolCallHook(tool, params.hookContext));
// in pi-tools.before-tool-call.ts
const wrappedTools = new WeakSet<object>();
export function wrapToolWithBeforeToolCallHook(tool: AnyAgentTool, ctx?: HookContext): AnyAgentTool {
// ... create wrappedTool ...
wrappedTools.add(wrappedTool as object);
return wrappedTool;
}
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
return wrappedTools.has(tool as object);
}If the intent is to avoid double wrapping only for trusted internal code paths, consider making the “already wrapped” optimization internal-only (not based on untrusted inputs), or accept double-wrapping but ensure idempotency inside the wrapper. 2. 🟠 Prototype pollution / unsafe parameter merge in before_tool_call hook overrides
DescriptionThe Impact:
Vulnerable code: if (isPlainObject(originalParams)) {
return { ...originalParams, ...approvalParams };
}RecommendationSanitize/disallow prototype-mutating keys when incorporating hook-provided params, and ensure the merged object has a safe prototype. Suggested fix (shallow merge with key filtering and null-prototype target): const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function safeShallowMerge(
base: Record<string, unknown>,
overrides: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = Object.create(null);
for (const [k, v] of Object.entries(base)) {
if (!FORBIDDEN_KEYS.has(k)) out[k] = v;
}
for (const [k, v] of Object.entries(overrides)) {
if (!FORBIDDEN_KEYS.has(k)) out[k] = v;
}
return out;
}
// ...
if (isPlainObject(originalParams) && isPlainObject(approvalParams)) {
return safeShallowMerge(originalParams, approvalParams);
}Additionally:
3. 🟠 Tool result middleware can spoof media trust and persist unsafe/local media URLs
DescriptionIn Because media URL filtering (
This is a trust-boundary issue: extension middleware can arbitrarily rewrite security-relevant metadata in tool results in a way that bypasses the “external tool” media restriction and can lead to unsafe content being persisted/logged/telemetry’d. Vulnerable flow:
Vulnerable code: const rawResult = await tool.execute(call.callId, preparedArgs, params.signal);
const result = await extensionRunner.applyToolResultExtensions({ ... , result: rawResult });
collectToolTelemetry({ toolName: tool.name, args, result, telemetry, isError: false });RecommendationMake media-trust decisions based on tool identity/source, not on mutable tool-result fields that middleware can rewrite. Concrete options:
const rawResult = await tool.execute(...);
const mediaTrustContext = {
toolName: tool.name,
isExternal: isExternalToolResult(rawResult),
};
const result = await extensionRunner.applyToolResultExtensions({ ..., result: rawResult });
collectToolTelemetry({ ..., result, mediaTrustContext });Then in telemetry filtering, if
4. 🟡 Dynamic tool bridge exposes raw exception messages to user and plugins
DescriptionThe dynamic tool bridge returns the caught exception text directly to the model/user and also forwards it to the
Vulnerable code: error: error instanceof Error ? error.message : String(error),
...
text: error instanceof Error ? error.message : String(error),RecommendationReturn a generic, user-safe error message, while logging the full exception server-side. If you need to expose details to trusted plugins, pass a structured error object with a sanitized message and an opaque error code. Example: } catch (err) {
const errorId = crypto.randomUUID();
log.error({ errorId, err }, "dynamic tool execution failed");
const safeMessage = "Tool execution failed";
void runAgentHarnessAfterToolCallHook({
toolName: tool.name,
toolCallId: call.callId,
runId: params.hookContext?.runId,
startArgs: args,
// Only safe info:
error: `${safeMessage} (id: ${errorId})`,
startedAt,
});
return {
success: false,
contentItems: [{ type: "inputText", text: safeMessage }],
};
}Optionally gate detailed error forwarding behind an explicit debug/privileged mode and never include stack traces/tokens in user-visible messages. Analyzed PR: #70965 at commit Last updated on: 2026-04-24T06:11:14Z |
Greptile SummaryThis PR fixes a hook-parity gap where OpenClaw dynamic tools entering the Codex app-server bridge could bypass Confidence Score: 5/5Safe to merge — the fix is narrow, well-tested, and backward compatible. All remaining findings are P2 or lower. The core logic is correct: wrapped tools retain all properties via spread, the Symbol-based detection is module-singleton safe, consumeAdjustedParamsForToolCall correctly threads adjusted params from the wrapper to after_tool_call, and the error/block paths are covered by tests. No files require special attention. Reviews (1): Last reviewed commit: "fix codex dynamic tool hooks" | Re-trigger Greptile |
|
@steipete @pashpashpash @vincentkoc The harness boundary is clean; the runtime ownership boundary is not. The Pi → Codex shift exposed an architectural gap: OpenClaw has a clean harness boundary, but not yet clean runtime contracts. The harness SPI itself looks sound and works. The issue is that OpenClaw-owned runtime policy is still scattered across the Pi runner, Codex app-server glue, transports, tools, auth, prompt overlays, transcript repair, delivery, and fallback. As Codex takes over more execution paths, it can bypass or reassemble policy that Pi previously owned implicitly. That means local fixes can look correct in isolation while still regressing another path (even 5.5 isn't catching this until it touches every single piece). This is why we’re seeing whack-a-mole behavior: the system lacks executable parity contracts that prove Pi and Codex preserve the same OpenClaw-owned behavior. My recommendation is to pause broad patch churn. Continuing to patch individual symptoms is likely to keep generating secondary regressions, and we may end up rebuilding the same layer again in 1-2 weeks. I think we should move contract-first:
This is not just cleanup. It’s risk control. With live customers and a large install base in the millions, we probably need a higher-discipline path for architecture changes: contract tests before refactors, smaller reversible PR ladders, explicit ownership boundaries, and no major runtime migration without parity gates. One more process issue: when we break something, we tend to rapidly release fixes, as with Codex + Anthropic, but without auto-update we leave many users stranded on broken intermediate builds instead of reliably rolling them forward or back. That makes runtime regressions much more expensive (trust and PR wise), so the pre-merge bar for hot-path architecture should probably be higher imo- we're not a beta app anymore businesses rely on us. |
|
Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd |
Codex mode is supposed to preserve OpenClaw behavior for tools that OpenClaw still owns. That matters because the Codex harness owns the model loop, but OpenClaw still executes dynamic tools like messaging, cron, sessions, and other runtime tools.
Before this change, the Codex app-server bridge assumed those dynamic tools had already been wrapped by the upstream OpenClaw tool assembly path. That is usually true in the normal run path, but the bridge itself did not enforce the contract. If an unwrapped OpenClaw tool reached the bridge, it could execute without
before_tool_callbehavior, which means plugins could miss the chance to block or adjust that tool call.This makes the bridge defensive. When Codex registers OpenClaw dynamic tools, the bridge now wraps any unwrapped tool with the existing OpenClaw
before_tool_callwrapper and leaves already-wrapped tools alone. The result is the intended dynamic-tool flow in Codex mode: Codex asks OpenClaw to run the tool, OpenClaw runsbefore_tool_call, executes the tool, applies dynamic tool result middleware, runsafter_tool_call, and then returns the result to Codex.The tests cover the parity cases this path needs: blocking a dynamic tool, adjusting params before execution, observing results through
after_tool_call, applying middleware before the after hook observes the result, reporting execution errors through the after hook, preserving messaging telemetry, and avoiding double-wrapping.This is intentionally scoped to OpenClaw-owned dynamic tools. It does not add app-server lifecycle projection or Codex-native shell/apply_patch hook relay support; those belong in the next PRs in the parity sprint.