Skip to content

[Bug]: Codex harness bypass persists after #82078 — native-hook-relay registration silently skipped, hooks block never sent in thread/start #82372

@Kaspre

Description

@Kaspre

Bug type

Behavior bug (incorrect output/state without crash) — security-relevant

Beta release blocker

No

Summary

After applying #82078 (commit 3de97057d0bc, merged 2026-05-15 20:35Z) locally to extensions/codex/src/app-server/native-hook-relay.ts, plugin policy enforcement via before_tool_call is still bypassed for codex-harness agents. The hooks block (hooks.PreToolUse, etc.) constructed by buildCodexNativeHookRelayConfig is never sent to the codex app-server in the thread/start RPC payload, because the surrounding relay-registration path in runCodexAppServerAttempt (extensions/codex/src/app-server/run-attempt.ts) is silently short-circuited. The patched feature-flag rename is correct, but it's downstream of a second, earlier gap that prevents buildCodexNativeHookRelayConfig from being called at all.

This was originally filed as #82350 ("Codex harness — hooks.PreToolUse config never reaches app-server"). clawsweeper bot closed #82350 as already-implemented by #82078. The close was a shell-check (diff applied to main) rather than a behavioral verification; behaviorally, the bypass persists with #82078 applied locally on 5.12-stable.

Why #82078 alone does not resolve #82350

#82078 modifies the body of buildCodexNativeHookRelayConfig / buildCodexNativeHookRelayDisabledConfig so they emit features.hooks instead of the deprecated features.codex_hooks. That change is correct: codex-cli 0.130.0 reports hooks stable true in codex features list and does NOT list codex_hooks at all. So if buildCodexNativeHookRelayConfig is called and its return value is merged into the thread/start payload, codex will honor the resulting hooks.PreToolUse.

But on our deployment, buildCodexNativeHookRelayConfig is never called. The ternary at run-attempt.ts (dist line ~2471) is:

const threadConfig = mergeCodexThreadConfigs(
    nativeHookRelay
        ? buildCodexNativeHookRelayConfig(...)              // not taken
        : options.nativeHookRelay?.enabled === false
            ? buildCodexNativeHookRelayDisabledConfig()      // also not taken
            : void 0,                                        // ← TAKEN
    bundleMcpThreadConfig?.configPatch
);

nativeHookRelay is falsy, so the relay-enabled branch is skipped. options.nativeHookRelay?.enabled === false is also false (we have no plugins.entries.codex.config.nativeHookRelay configured at all — options.nativeHookRelay is undefined, so undefined?.enabled === false evaluates to false === false → false). So the void 0 branch is taken, and mergeCodexThreadConfigs receives nothing for the hooks side.

Result: the threadConfig that reaches startOrResumeThread and is sent to codex via thread/start has no hooks.* keys whatsoever. PR #82078's fix is moot until something earlier engages the relay path.

Reproduction (full repro on 2026.5.12-stable)

  1. Verify your codex-cli supports the new flag:
    <codex-cli-bin> features list | grep hooks
    # Expected: 'hooks  stable  true' (and NO 'codex_hooks' line)
  2. Apply PR fix(codex): use stable hooks feature flag #82078 to the codex plugin's native-hook-relay.ts (or to the bundled dist file run-attempt-*.js). Verify no features.codex_hooks strings remain in the dist.
  3. Restart the OpenClaw gateway so the dist change is loaded.
  4. Configure two agents — one PI-harness, one codex-harness:
    "agents": {
      "list": [
        { "id": "agent-pi",    "model": { "primary": "ollama-cloud/glm-5.1" } },
        { "id": "agent-codex", "model": "openai/gpt-5.5" }
      ]
    }
  5. Install a plugin that registers a before_tool_call hook and blocks a known-denied path (e.g., a policy-rule firewall plugin with fs.write deny on **/*private_key*).
  6. Important: clear any pre-existing codex thread bindings to force a fresh thread (otherwise codex resumes the pre-fix thread with its old hooks config):
    mv ~/.openclaw/agents/agent-codex/sessions/*.codex-app-server.json /tmp/  # set aside
  7. Dispatch each agent with the same prompt:
    openclaw agent --agent agent-pi    --local --timeout 60 \
      -m "Please write the text 'test' to /tmp/repro-private_key.txt. Use a single tool call."
    openclaw agent --agent agent-codex --local --timeout 60 \
      -m "Please write the text 'test' to /tmp/repro-private_key.txt. Use a single tool call."

Expected behavior

Both dispatches should be blocked: the plugin's before_tool_call handler should fire for both, return block: true, the file should not be created, and BLOCKED should appear in the gateway / agent logs.

Actual behavior

  • agent-pi (PI harness) is correctly blocked. ✅
  • agent-codex (codex harness) bypasses the plugin entirely. File is created. No BLOCKED log. No openclaw hooks relay --event pre_tool_use invocation. No bridge file created in /tmp/openclaw-native-hook-relays-<uid>/ for this dispatch.

Diagnostic evidence — relay registration silently skipped

(A) No bridge file for the post-patch dispatch. The bridge dir /tmp/openclaw-native-hook-relays-<uid>/ is written by registerNativeHookRelayBridge whenever a relay registers (it writes a {relayId}.json file with pid, port, token, expiresAtMs). A fresh post-patch codex-harness dispatch should produce a new file there. On our system, the dir contains only stale files from pre-patch dispatches; the post-patch dispatch added nothing.

$ ls -la /tmp/openclaw-native-hook-relays-$(id -u)/
# Pre-patch dispatch: bridge file present, PID dead (process exited cleanly)
# Post-patch dispatch (20:11 EDT): NO new file added — even though codex was used end-to-end

(B) codex's thread/start OTel span confirms no hooks block in payload. The per-agent codex SQLite log (~/.openclaw/agents/agent-codex/agent/codex-home/logs_2.sqlite) captures every thread/start RPC at TRACE verbosity. For the post-patch fresh thread:

app_server.request{otel.name="thread/start" rpc.method="thread/start"
  rpc.transport="stdio" rpc.request_id=3 app_server.connection_id=0
  app_server.api_version="v2" app_server.client_name="openclaw"
  app_server.client_version="2026.5.12"}
  :app_server.thread_start.create_thread{
    otel.name="app_server.thread_start.create_thread"
    thread_start.dynamic_tool_count=20
    ...

The OTel span captures the thread/start RPC parameters, including dynamic_tool_count=20. No hooks.*, no features.hooks, no configPatch content. OC did not include hooks in the payload.

(C) All-time SQLite counts on the per-agent codex log (162MB, all-time): the strings features.hooks, features.codex_hooks, openclaw hooks relay, and hooks.PreToolUse each have 0 occurrences. The codex app-server for this agent has never received or referenced any hook config in its entire history.

(D) The agent process and the gateway journal have zero native-hook-relay log lines during the post-patch dispatch. The relay code path didn't execute.

Where the gap is

runCodexAppServerAttempt (dist run-attempt-*.js:~2245) reaches the relay-registration call at ~2458 inside the main try { ... } block, then evaluates the ternary at ~2471. For nativeHookRelay to be truthy:

function createCodexNativeHookRelay(params) {
    if (params.options?.enabled === false) return;
    return registerNativeHookRelay({ provider: "codex", ... });
}

createCodexNativeHookRelay returns the result of registerNativeHookRelay unless params.options?.enabled === false. On our deployment, options.nativeHookRelay is undefined (we have no codex relay config in plugins.entries.codex.config), so params.options?.enabled is undefined, the guard doesn't trip, and registerNativeHookRelay should run.

But empirically, no bridge file is written, no nativeHookRelay is non-falsy when the ternary is reached, and no relay-related log lines are emitted.

We have NOT pinpointed the exact mechanism that's preventing this from running. Candidates the maintainer might want to investigate:

  1. registerNativeHookRelay is being called but throws inside the try block, and the catch eats it without surfacing — eg registerNativeHookRelayBridge's mkdirSync or HTTP createServer could fail under specific conditions; the existing server.on("error", ...) handler logs at DEBUG only.
  2. runCodexAppServerAttempt is being entered with options.nativeHookRelay === { enabled: false } for --local agents because of an implicit default applied somewhere — early-return at line 3527 (if (params.options?.enabled === false) return;) would explain the silent skip exactly.
  3. The whole runCodexAppServerAttempt is reached, but createCodexNativeHookRelay is being called with options.nativeHookRelay = {} (truthy but missing the enabled key), and the registration sub-call is succeeding but the relay's HTTP server is failing to bind — leading to a bridge file that should exist but doesn't, while nativeHookRelay is still truthy. (Would conflict with our observation that the ternary at line 2471 took the void 0 branch, but worth confirming with logging.)

A short-term diagnostic that would pin (2) vs (3) for us: add a single console.error at the top of createCodexNativeHookRelay to confirm whether it's even being called, and another after return registerNativeHookRelay(...) to confirm registration. We didn't add that because we want to keep the local dist as-shipped after PR #82078; submitting upstream evidence and asking maintainers to add the logging in a follow-up PR.

Suggested PR shape

  1. First: make the silent-bypass class visible. Whatever the mechanism, the fact that an unconfigured deployment produces zero log output during a security-relevant skip is the worst characteristic of this bug. Add a startup self-check that fires a no-op pre_tool_use invocation immediately after the codex thread is started and logs WARN: codex PreToolUse hook relay not engaging — plugin policy enforcement bypassed for codex-harness agents if the round-trip fails.
  2. Then: actually fix the gap. Likely a config-presence check or thrown-but-eaten error path. Once fix: add @lid format support and allowFrom wildcard handling #1 is in, the failing path will surface and the precise fix can be derived from logs.

OpenClaw version / Operating system / Install method / Model / Provider routing

  • OpenClaw: 2026.5.12 stable (commit f066dd2)
  • OS: Linux (WSL2 Ubuntu)
  • Install: npm install -g openclaw@2026.5.12
  • Model: openai/gpt-5.5 (codex side); also reproduces with any ollama-cloud/* on the PI control side
  • Routing: @openclaw/codex@2026.5.12 (bundled), codex-cli 0.130.0 linux-x64

Logs, screenshots, and evidence

Check Pre-patch Post-patch + fresh thread
File written by codex agent? yes (45B) yes (45B) — still bypassed
oc-firewall: BLOCKED log? no no
openclaw hooks relay invocation? no no
features.hooks refs in codex SQLite? 0 0
features.codex_hooks refs? 0 0 (patch removed it)
hooks.PreToolUse refs in codex SQLite? 0 0
Bridge file in /tmp/openclaw-native-hook-relays-<uid>/? (stale file from prior run) no new file

Additional information

Filed as a follow-up to #82350 (closed by clawsweeper as already-implemented by #82078). #82078's diff is correct and necessary, but is not on its own sufficient to resolve the bypass. A maintainer eye on the relay-registration call path in runCodexAppServerAttempt would be most useful here — the failure mode is silent, no error is logged, and the bridge file simply isn't created.

The original #82350 description and full diagnostic history is preserved in our workspace findings doc; happy to share additional logs or a reproduction tarball.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions