Skip to content

Preserve dynamic tool hooks in Codex mode#70965

Merged
pashpashpash merged 1 commit intomainfrom
codex/dynamic-tool-hook-parity
Apr 24, 2026
Merged

Preserve dynamic tool hooks in Codex mode#70965
pashpashpash merged 1 commit intomainfrom
codex/dynamic-tool-hook-parity

Conversation

@pashpashpash
Copy link
Copy Markdown
Contributor

@pashpashpash pashpashpash commented Apr 24, 2026

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_call behavior, 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_call wrapper 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 runs before_tool_call, executes the tool, applies dynamic tool result middleware, runs after_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.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 24, 2026

🔒 Aisle Security Analysis

We found 4 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Before-tool-call hook bypass via spoofable wrapped-marker check (Proxy tools)
2 🟠 High Prototype pollution / unsafe parameter merge in before_tool_call hook overrides
3 🟠 High Tool result middleware can spoof media trust and persist unsafe/local media URLs
4 🟡 Medium Dynamic tool bridge exposes raw exception messages to user and plugins
1. 🟠 Before-tool-call hook bypass via spoofable wrapped-marker check (Proxy tools)
Property Value
Severity High
CWE CWE-285
Location extensions/codex/src/app-server/dynamic-tools.ts:47-51

Description

createCodexDynamicToolBridge now conditionally skips wrapToolWithBeforeToolCallHook() when isToolWrappedWithBeforeToolCallHook(tool) returns true. The wrapped status is determined by reading a module-private Symbol property on the tool object.

In JavaScript/TypeScript, an untrusted tool provider can supply a Proxy object that returns true for any property access (including unknown symbols). This allows the tool to claim it is wrapped while actually providing an execute() that never runs runBeforeToolCallHook(), bypassing enforcement performed by the before_tool_call hook (blocking, approval gating, parameter normalization/loop detection).

Vulnerable logic:

const tools = params.tools.map((tool) =>
  isToolWrappedWithBeforeToolCallHook(tool)
    ? tool
    : wrapToolWithBeforeToolCallHook(tool, params.hookContext),
);

Because isToolWrappedWithBeforeToolCallHook() performs a simple property read (taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true), a Proxy can spoof the marker without knowing the symbol value.

Recommendation

Do not rely on a property on the tool object to decide whether to enforce before_tool_call.

Prefer one of:

  1. Always wrap tools at the boundary where untrusted tools enter (simplest):
const tools = params.tools.map((tool) => wrapToolWithBeforeToolCallHook(tool, params.hookContext));
  1. Track wrapped tools in a module-private WeakSet (cannot be spoofed by Proxy/property tricks):
// 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
Property Value
Severity High
CWE CWE-1321
Location src/agents/pi-tools.before-tool-call.ts:46-55

Description

The before_tool_call hook can return params that are merged into the tool's original arguments using object spread. Because the merge does not block special keys like __proto__, constructor, or prototype, a malicious (or compromised) plugin can inject these properties and alter the prototype of the resulting params object (or otherwise introduce surprising inherited properties).

Impact:

  • A plugin can return hookResult.params containing __proto__ to change the prototype of the merged params object.
  • Tool implementations that subsequently rely on property checks (e.g., in, hasOwnProperty, truthiness checks, schema validation that doesn't guard inherited props) can be bypassed.
  • The polluted params object is passed directly to tool execute(...), potentially enabling unexpected argument injection into sensitive tools (e.g., exec/gateway/message tools).

Vulnerable code:

if (isPlainObject(originalParams)) {
  return { ...originalParams, ...approvalParams };
}

Recommendation

Sanitize/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:

  • Consider validating hookResult.params against the tool's input schema (or at least ensuring only existing/allowed keys are overridden).
  • If deep merges are introduced later, use a prototype-pollution-safe merge implementation.
3. 🟠 Tool result middleware can spoof media trust and persist unsafe/local media URLs
Property Value
Severity High
CWE CWE-345
Location extensions/codex/src/app-server/dynamic-tools.ts:82-97

Description

In createCodexDynamicToolBridge.handleToolCall, tool results are passed through Codex app-server extension middleware (tool_result) before telemetry extraction/filtering and before after_tool_call hooks receive the result.

Because media URL filtering (filterToolResultMediaUrls) decides whether to allow non-HTTP(S) paths based on mutable fields in the tool result (notably details.mcpServer / details.mcpTool checked by isExternalToolResult()), a middleware extension can:

  • Strip details.mcpServer / details.mcpTool (making an external/MCP result appear “non-external”)
  • Inject details.media.mediaUrls or MEDIA: tokens containing local file paths (e.g. /etc/passwd, file:///..., MEDIA:/tmp/...)
  • Cause extractToolResultMediaArtifact() + filterToolResultMediaUrls() to treat the tool as trusted (based on toolName) and then persist these local paths into toolTelemetry.toolMediaUrls, which are included in the turn snapshot (event-projector.ts)

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:

  • tool execution: rawResult = await tool.execute(...)
  • extension rewrite: result = await extensionRunner.applyToolResultExtensions(...)
  • media extraction/filtering uses rewritten result and rewritten result.details

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 });

Recommendation

Make media-trust decisions based on tool identity/source, not on mutable tool-result fields that middleware can rewrite.

Concrete options:

  1. Compute external/trusted status before middleware and carry it alongside the result:
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 mediaTrustContext.isExternal === true, only allow http(s) regardless of how middleware rewrites result.details.

  1. Pass the run’s registered tool-name set into filterToolResultMediaUrls(..., builtinToolNames) to prevent name-collision trust inheritance, and additionally treat any result touched by extensions as untrusted for local media unless explicitly whitelisted.

  2. If extensions are not fully trusted, enforce that tool_result middleware may only redact/transform content but cannot introduce new media URLs/paths; e.g., diff the extracted media URLs before/after middleware and drop any additions unless the tool is trusted.

4. 🟡 Dynamic tool bridge exposes raw exception messages to user and plugins
Property Value
Severity Medium
CWE CWE-209
Location extensions/codex/src/app-server/dynamic-tools.ts:113-137

Description

The dynamic tool bridge returns the caught exception text directly to the model/user and also forwards it to the after_tool_call hook.

  • Any dynamic tool can throw an Error whose .message may contain sensitive internal details (filesystem paths, internal hostnames, command output, credentials embedded in error strings, etc.).
  • Non-Error throws are stringified via String(error), which can leak serialized objects.
  • The same unsanitized error string is sent to plugins via runAgentHarnessAfterToolCallHook, expanding the disclosure surface.

Vulnerable code:

error: error instanceof Error ? error.message : String(error),
...
text: error instanceof Error ? error.message : String(error),

Recommendation

Return 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 efa2d1f

Last updated on: 2026-04-24T06:11:14Z

@pashpashpash pashpashpash changed the title Fix Codex dynamic tool hook parity Preserve dynamic tool hooks in Codex mode Apr 24, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR fixes a hook-parity gap where OpenClaw dynamic tools entering the Codex app-server bridge could bypass before_tool_call and after_tool_call plugin hooks. The fix wraps any unwrapped tool at bridge initialization time (guarded by isToolWrappedWithBeforeToolCallHook to prevent double-wrapping), exports the two helper functions through the plugin-SDK surface, and adds five integration tests covering normal execution, blocking, middleware ordering, error propagation, and the double-wrap prevention.

Confidence Score: 5/5

Safe 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

@pashpashpash pashpashpash merged commit 81666e5 into main Apr 24, 2026
70 of 72 checks passed
@pashpashpash pashpashpash deleted the codex/dynamic-tool-hook-parity branch April 24, 2026 07:14
@100yenadmin
Copy link
Copy Markdown
Contributor

100yenadmin commented Apr 24, 2026

@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:

  1. Add Pi/Codex parity tests for tool hooks, auth/profile resolution, prompt overlays, schema normalization, transcript repair, delivery, fallback, and NO_REPLY.
  2. Introduce a shared prepared runtime plan so OpenClaw owns policy once and Pi/Codex consume it.
  3. Let Codex own app-server/model-loop lifecycle, but not reimplement OpenClaw runtime policy.
  4. Only after behavior is locked, split/rename/refactor the runner.

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.

#71004
#71009

@openclaw-barnacle
Copy link
Copy Markdown

Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd

Angfr95 pushed a commit to Angfr95/openclaw that referenced this pull request Apr 25, 2026
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants