Skip to content

Extend native tool interception across the harness fleet - #2392

Closed
xeophon wants to merge 9 commits into
agent/native-tool-interceptionfrom
agent/native-tool-interception-harnesses
Closed

Extend native tool interception across the harness fleet#2392
xeophon wants to merge 9 commits into
agent/native-tool-interceptionfrom
agent/native-tool-interception-harnesses

Conversation

@xeophon

@xeophon xeophon commented Aug 18, 2026

Copy link
Copy Markdown
Member

Overview

Stacked on #2372. Extends synchronous native tool interception from Bash + Pi to every harness that owns a synchronous execution boundary, and documents why the rest cannot gate tools natively. Every wired hook blocks a proposed call before execution and runs post-result policy before the harness advances — a hook that cannot reach interception (or cannot preserve an approved result byte-exact) fails the rollout loudly instead of letting harness state diverge from the canonical trace.

Details

  • null / browser-use (in-repo loops): same reference flow as Bash — before hook gates each call (a rewrite skips execution and records the policy's result), after hook runs before the result is appended; the tool-policy bearer arrives over stdin via run_with_input, never argv or env.
  • Hermes Agent: a generated plugin registers tool_execution middleware (pre-execution gate) and a transform_tool_result hook (post-execution policy). Transport failures and stops hard-exit the agent process, so a vetoed tool can never run. Project plugins are disabled so no task-owned code joins the credential-bearing process.
  • Claude Code: the ACP adapter is wrapped in-process to inject PreToolUse / PostToolUse / PostToolUseFailure SDK hooks; credentials travel through private ACP session metadata, and settingSources is pinned to user so tasks cannot merge their own hooks. Deny carries a rewrite as the permission reason; PostToolUse rewrites via updatedToolOutput with the content mappings bounded to shapes the pinned release preserves.
  • OpenClaw: a generated Gateway plugin gates before_tool_call and runs the result middleware; pre-execution rewrites are applied to the emitted synthetic result so OpenClaw records exactly the approved content. The plugin allowlist keeps workspace plugins out of the Gateway.
  • Shared install_tool_hook bootstrap (one-shot private credentials file, delivered over stdin) reused by Pi, Hermes, and OpenClaw; both Node hook stacks set NODE_USE_ENV_PROXY so fetch reaches interception under restricted networking.
  • codex / kimi-code / pool / mini-swe-agent / terminus-2 / rlm: short in-code notes on why no synchronous native boundary exists (no per-tool hook surface, closed binary, or text-action agents with no tool calls to key on).
  • Revives the tool-interception example environment (block → rewrite → observe-failure in one deterministic rollout) and the e2e rows for bash, claude-code, hermes, openclaw, and pi, including the restricted-network variants.

Validation

  • ruff check / ruff format / pre-commit run --all-files clean; pytest tests/v1 -m "not e2e" and tests/test_interception_utils.py pass.
  • Live e2e rows (test_native_tool_interception, ..._restricted) need a model key + Docker and were not run here; they are the per-pinned-version verification gate for the three CLI hooks.

Note

High Risk
Large cross-harness change touching security-sensitive tool gating, credential handoff, and pinned third-party hook contracts; incorrect interception or hook mapping could let tools run when blocked or diverge traces from policy.

Overview
Extends synchronous native tool interception beyond Bash/Pi so rollouts can block tools before execution, rewrite results before the agent sees them, and observe failures on every harness that owns a real tool boundary. Harnesses that cannot gate tools natively get short in-code notes (Codex, Kimi Code, Pool, mini-swe-agent, Terminus, RLM).

In-repo chat loops (null, browser-use) now call the rollout /tool policy on before and after each tool call; a pre-execution rewrite skips running the tool. The policy bearer is delivered via stdin (run_with_input), not argv or env.

ACP agents wire native hooks/plugins per stack: Claude Code wraps the ACP adapter with SDK Pre/Post hooks and pins settingSources to user; Hermes installs a generated plugin with fail-closed tool_execution / transform_tool_result; OpenClaw loads a Gateway plugin for before_tool_call and result middleware with an allowlisted plugin set. Shared install_tool_hook delivers one-shot credentials privately for Pi, Hermes, and OpenClaw.

Adds the tool-interception example environment (three-step deterministic rollout + reward checks) and e2e rows for bash, claude-code, hermes, openclaw, and pi, including restricted Docker networking variants. Test conftest exposes the example package on PYTHONPATH alongside fixtures.

Reviewed by Cursor Bugbot for commit b68ad58. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Extend native tool interception to browser-use, claude-code, hermes-agent, null, and openclaw harnesses

  • Adds SUPPORTS_TOOL_INTERCEPTION=True and configure_tool_interception to browser-use, claude-code, hermes-agent, null, and openclaw harnesses; secrets are delivered securely over stdin using a length-prefix protocol.
  • Each harness installs a native hook (JS plugin or Python middleware) that calls an interception URL before and after tool execution, supporting allow/rewrite/stop decisions.
  • Adds a shared install_tool_hook helper that writes hook source and credentials to a private 0600 file, returning a VF_TOOL_INTERCEPTION_CONFIG env var; pi harness is refactored to use it.
  • Adds a tool-interception rollout environment with a deterministic 3-step task that exercises blocking, rewriting, and failure observation, plus e2e tests covering both standard and restricted (network-isolated) Docker placements.
  • Codex, kimi-code, mini-swe-agent, pool, rlm, and terminus-2 harnesses are documented as not supporting interception with explanatory comments.
📊 Macroscope summarized b68ad58. 22 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

Comment on lines +190 to +197
if (hook.hook_event_name === "PreToolUse") {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: contentText(content),
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium claude_code/tool_hook.mjs:190

A PreToolUse rewrite with array content is converted into a newline-joined string, so text arrays lose their shape and image parts become JSON text; Claude therefore records a result different from the policy-approved message. Reject non-string pre-tool rewrites instead of passing them through contentText, allowing the hook to fail closed.

   if (hook.hook_event_name === "PreToolUse") {
+    if (typeof content !== "string") {
+      throw new Error("Claude cannot preserve the rewritten PreToolUse content");
+    }
     return {
       hookSpecificOutput: {
         hookEventName: "PreToolUse",
         permissionDecision: "deny",
         permissionDecisionReason: contentText(content),
       },
     };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/claude_code/tool_hook.mjs around lines 190-197:

A `PreToolUse` rewrite with array content is converted into a newline-joined string, so text arrays lose their shape and image parts become JSON text; Claude therefore records a result different from the policy-approved message. Reject non-string pre-tool rewrites instead of passing them through `contentText`, allowing the hook to fail closed.

}
client = AsyncOpenAI(base_url=args.base_url, api_key=args.api_key)
tool_client = (
httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High browser_use/program.py:373

run_tool_hook waits indefinitely when the interception endpoint accepts the connection but never responds, so a stalled service hangs the browser-use rollout instead of failing within a bounded timeout. httpx.Timeout(None, connect=5.0) disables the read, write, and pool timeouts; set a finite overall timeout while retaining the 5-second connect timeout.

Suggested change
httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=5.0))
Also found in 1 other location(s)

verifiers/v1/harnesses/null/program.py:203

tool_client uses httpx.Timeout(None, connect=5.0), disabling read/write/pool timeouts. An interception endpoint that accepts a connection and then stalls causes run_tool_hook to wait indefinitely, hanging the null-harness rollout instead of failing closed within a bounded time.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/browser_use/program.py around line 373:

`run_tool_hook` waits indefinitely when the interception endpoint accepts the connection but never responds, so a stalled service hangs the browser-use rollout instead of failing within a bounded timeout. `httpx.Timeout(None, connect=5.0)` disables the read, write, and pool timeouts; set a finite overall timeout while retaining the 5-second connect timeout.

Also found in 1 other location(s):
- verifiers/v1/harnesses/null/program.py:203 -- `tool_client` uses `httpx.Timeout(None, connect=5.0)`, disabling read/write/pool timeouts. An interception endpoint that accepts a connection and then stalls causes `run_tool_hook` to wait indefinitely, hanging the null-harness rollout instead of failing closed within a bounded time.

Comment on lines +89 to +91
converted.every((part) => part.type === "text") &&
converted.reduce((size, part) => size + part.text.length, 0) >
CLAUDE_MAX_INLINE_TEXT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium claude_code/tool_hook.mjs:89

An MCP result containing an image and more than 50,000 characters of text is approved inline even though Claude persists oversized text after the hook, so the model-visible result diverges from the canonical trace. The size check is gated by converted.every((part) => part.type === "text"); enforce the text-length limit across mixed image/text content as well.

-    converted.every((part) => part.type === "text") &&
-    converted.reduce((size, part) => size + part.text.length, 0) >
+    converted.reduce(
+      (size, part) => size + (part.type === "text" ? part.text.length : 0),
+      0,
+    ) >
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/claude_code/tool_hook.mjs around lines 89-91:

An MCP result containing an image and more than 50,000 characters of text is approved inline even though Claude persists oversized text after the hook, so the model-visible result diverges from the canonical trace. The size check is gated by `converted.every((part) => part.type === "text")`; enforce the text-length limit across mixed image/text content as well.

hook.tool_response.isImage === false &&
hook.tool_response.persistedOutputPath === undefined
) {
return claudeContent(hook.tool_response.stdout, hook.tool_name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium claude_code/tool_hook.mjs:113

The structured Bash allow path accepts stdout values from 30,001 through 50,000 characters, even though Bash preservation is limited to 30,000, so Claude can persist or transform the approved result and diverge from the canonical trace. This path sends stdout through claudeContent, which applies the generic 50,000-character limit; enforce CLAUDE_BASH_MAX_INLINE_TEXT before that call.

-    return claudeContent(hook.tool_response.stdout, hook.tool_name);
+    if (hook.tool_response.stdout.length > CLAUDE_BASH_MAX_INLINE_TEXT) {
+      throw new Error(`Claude will persist ${hook.tool_name}'s output after its hook`);
+    }
+    return claudeContent(hook.tool_response.stdout, hook.tool_name);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/harnesses/claude_code/tool_hook.mjs around line 113:

The structured Bash allow path accepts `stdout` values from 30,001 through 50,000 characters, even though Bash preservation is limited to 30,000, so Claude can persist or transform the approved result and diverge from the canonical trace. This path sends `stdout` through `claudeContent`, which applies the generic 50,000-character limit; enforce `CLAUDE_BASH_MAX_INLINE_TEXT` before that call.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b68ad58. Configure here.

hook.tool_response.isImage === false &&
hook.tool_response.persistedOutputPath === undefined
) {
return claudeContent(hook.tool_response.stdout, hook.tool_name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Bash hook drops stderr

High Severity

claudeToolContent only forwards Bash stdout into the /tool after-hook, while the revived tool-interception example emits its failure marker on stderr only. On Claude Code that marker never reaches policy or the prepared result, so failure observation and the Claude e2e reward checks cannot pass.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b68ad58. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a significant new feature - native tool interception across multiple harnesses - with substantial runtime behavior changes and new integration points. Additionally, there are unresolved High-severity findings including potential bugs with stderr handling and indefinite timeout issues.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@xeophon
xeophon force-pushed the agent/native-tool-interception branch from 412a3f1 to d22f55d Compare August 18, 2026 15:28
@xeophon xeophon closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant