openclaw - ✅(Solved) Fix [Feature]: Plugin hook API: add async tool result mutation hook before transcript write [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#62123Fetched 2026-04-08 03:08:41
View on GitHub
Comments
0
Participants
1
Timeline
1
Reactions
0
Participants
Timeline (top)
labeled ×1

Add an async plugin hook that fires after tool execution completes but before the result is written to the session transcript, capable of returning a modified or suppressed result.

Error Message

logger?.warn(handler returned a Promise; this hook is synchronous and the result was ignored.);

Root Cause

Add an async plugin hook that fires after tool execution completes but before the result is written to the session transcript, capable of returning a modified or suppressed result.

Fix Action

Fix / Workaround

• Affected: Any plugin implementing security validation, prompt injection defense, content moderation, or data sanitization on external tool results — particularly web search and crawl tools • Severity: Blocks workflow — this entire class of plugin cannot be built correctly with the current API • Frequency: Always — architectural gap, not an edge case • Consequence: Plugins defending against indirect prompt injection (OWASP LLM01:2025 #1 risk for LLM applications) have no clean intercept point. Production deployments calling external web tools are exposed with no plugin-level mitigation path.

PR fix notes

PR #64907: Plugin SDK: add text-only tool_result_before_model hook

Description (problem / solution / changelog)

Summary

  • Problem: plugins had no typed seam to canonicalize successful tool-result text before it became model-visible by default, so noisy tool output could leak into same-turn and future-turn context.
  • What changed: added synchronous tool_result_before_model as an early hook in the embedded tool_result chain, and classified it as a prompt-injecting hook so existing allowPromptInjection=false policy continues to apply.
  • Final scope: the hook is text-only and content-only. It only runs for successful tool results whose content is exactly one text block, leaves details untouched, and skips legacy plain-string content.
  • Behavior: if a handler returns { text }, later same-turn tool_result handlers see that text as the new base, default transcript persistence follows the final emitted tool result, and hook failures fail open.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #34144
  • Related #14544
  • Related #62123
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: N/A
  • Missing detection / guardrail: N/A
  • Contributing context (if known): N/A

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file:
    • src/plugins/hooks.tool-result-before-model.test.ts
    • src/plugins/hooks.sync-only.test.ts
    • src/agents/pi-embedded-runner/extensions.test.ts
    • src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts
    • src/agents/pi-embedded-runner/tool-result-context-guard.test.ts
    • src/agents/pi-embedded-runner/tool-result-truncation.test.ts
  • Scenario the test should lock in: successful tool results with exactly one text block can be canonicalized early, later ordinary tool_result handlers run on canonical text as the new base, downstream rewrites should use explicit returned content patches, mutation-only side effects continue to follow the existing runner semantics, raw details stay untouched, default transcript persistence follows the final emitted tool result, and legacy plain-string content is skipped.
  • Why this is the smallest reliable guardrail: the behavior crosses the public hook contract, the embedded runner tool_result chain, and transcript truncation/persistence boundaries, so seam-level coverage is the narrowest reliable level.
  • Existing test that already covers this (if any): the targeted seam/integration suites above cover the touched contract and runtime boundaries.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

Plugin authors can now use tool_result_before_model to rewrite a single model-facing tool-result text payload before same-turn continuation.

In v0, the hook only runs for successful tool results whose content is exactly one text block. It leaves details untouched, skips legacy plain-string content, and default transcript persistence follows the final emitted tool result.

There are no new config knobs or UI changes.

Diagram (if applicable)

Before:
[tool returns raw text block]
  -> [later tool_result handlers see raw text]
  -> [default transcript stores raw text]

After:
[tool returns raw text block]
  -> [tool_result_before_model canonicalizes text]
  -> [later tool_result handlers see canonical text as the new base]
  -> [default transcript stores final emitted text]
  -> [optional tool_result_persist may still rewrite transcript-specific output]

Security Impact (required)

  • New permissions/capabilities? (Yes)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

This adds a new trusted-plugin capability to rewrite only model-facing text for successful tool results before same-turn continuation and default transcript persistence. The scope is intentionally narrow: it only runs when tool-result content is exactly one text block, it never rewrites runtime details, it skips legacy plain-string content, and handler failures fail open. It does not grant new tool permissions, network access, secret access, or host-level raw artifact access. Risk remains bounded by the existing plugin trust model, the synchronous, content-only contract, and the existing allowPromptInjection=false host policy, which now blocks this hook for policy-restricted plugins.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local repo checkout
  • Integration/channel (if any): embedded runner + local test/build gates
  • Relevant config (redacted): local plugin SDK development branch

Steps

  1. Register a tool_result_before_model handler that returns { text: ... }.
  2. Run a successful tool result whose content is exactly one text block through the embedded tool_result chain.
  3. Verify that later ordinary tool_result handlers see canonical text as the new base, raw details stay untouched, and legacy plain-string content is skipped.

Expected

  • The hook only runs for successful tool results with exactly one text block.
  • Later ordinary tool_result handlers run on canonical text as the new base; downstream rewrites should use explicit returned content patches, while mutation-only side effects continue to follow the existing runner semantics.
  • Raw details remain unchanged by default.
  • Default transcript persistence follows the final emitted tool result.
  • Legacy plain-string content is skipped.

Actual

  • Verified locally through targeted seam/integration tests and build validation on this branch.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Notes:

  • Branch-local verification commands passed:
    • OPENCLAW_LOCAL_CHECK=0 pnpm test src/plugins/hooks.tool-result-before-model.test.ts src/plugins/hooks.sync-only.test.ts src/agents/pi-embedded-runner/extensions.test.ts src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts src/agents/pi-embedded-runner/tool-result-context-guard.test.ts src/agents/pi-embedded-runner/tool-result-truncation.test.ts
    • OPENCLAW_LOCAL_CHECK=0 pnpm build
  • The screenshots below are preserved from the earlier local demo of same-turn canonicalization and are retained as illustrative evidence only; the authoritative final contract for this PR is the narrowed text-only behavior described above and covered by the targeted tests.

Screenshots:

Before: <img width="1526" height="660" alt="before-1" src="https://github.com/user-attachments/assets/938a401d-40a5-49ac-9f6c-401e08c14355" /> <img width="592" height="611" alt="before-2" src="https://github.com/user-attachments/assets/22a9f898-2a76-4fb4-8fdf-6603d7ca5071" />

After: <img width="1554" height="752" alt="after" src="https://github.com/user-attachments/assets/49c3e4bc-7fbf-45cd-8eb3-33eab15373a9" />

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios:
    • early same-turn text canonicalization for successful tool results with exactly one text block
    • later ordinary tool_result handlers see canonical text as the new base
    • later ordinary tool_result handlers run on canonical text as the new base; explicit returned content patches remain covered in the targeted tests, while mutation-only side effects continue to follow the existing runner semantics
    • raw details remain untouched by this hook
    • default transcript persistence follows the final emitted tool result
    • legacy plain-string tool results are skipped
  • Edge cases checked:
    • multi-block tool results are skipped
    • mixed text and non-text tool results are skipped
    • non-text tool results are skipped
    • hook failures fail open
  • What you did not verify:
    • async hook semantics
    • any details canonicalization path
    • multi-block or non-text canonicalization beyond skip behavior
    • a fresh UI demo for the final narrowed contract

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Risks and Mitigations

  • Risk:

    • trusted plugins can now rewrite successful model-facing tool-result text before same-turn continuation and default transcript persistence
    • Mitigation:
      • limited to trusted plugins
      • synchronous typed hook only
      • content-only scope
      • raw details untouched
      • fail-open behavior
      • focused seam and integration coverage
  • Risk:

    • plugin authors may assume this hook also supports metadata rewriting or legacy plain-string tool results
    • Mitigation:
      • v0 is explicitly limited to exactly one text block
      • legacy plain-string content is skipped
      • metadata and transcript-specific shaping remain in tool_result_persist

Changed files

  • docs/.generated/plugin-sdk-api-baseline.sha256 (modified, +2/-2)
  • docs/concepts/agent-loop.md (modified, +1/-0)
  • docs/plugins/building-plugins.md (modified, +1/-0)
  • docs/plugins/sdk-overview.md (modified, +1/-0)
  • src/agents/pi-embedded-runner/compact.hooks.harness.ts (modified, +3/-4)
  • src/agents/pi-embedded-runner/compact.ts (modified, +12/-2)
  • src/agents/pi-embedded-runner/extensions.test.ts (modified, +439/-2)
  • src/agents/pi-embedded-runner/extensions.ts (modified, +149/-1)
  • src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts (modified, +1/-0)
  • src/agents/pi-embedded-runner/run/attempt.ts (modified, +13/-5)
  • src/agents/pi-embedded-runner/tool-result-context-guard.test.ts (modified, +17/-0)
  • src/agents/pi-embedded-subscribe.handlers.tools.test.ts (modified, +55/-0)
  • src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts (modified, +327/-109)
  • src/agents/session-tool-result-guard.ts (modified, +4/-0)
  • src/plugins/hook-types.ts (modified, +44/-0)
  • src/plugins/hooks.sync-only.test.ts (modified, +139/-1)
  • src/plugins/hooks.tool-result-before-model.test.ts (added, +142/-0)
  • src/plugins/hooks.ts (modified, +85/-4)
  • src/plugins/loader.test.ts (modified, +37/-0)
  • src/plugins/registry.ts (modified, +1/-1)
RAW_BUFFERClick to expand / collapse

Summary

Add an async plugin hook that fires after tool execution completes but before the result is written to the session transcript, capable of returning a modified or suppressed result.

Problem to solve

The current hook API provides no way to asynchronously validate or transform a tool result before the LLM sees it. after_tool_call is async but void — return values are discarded. tool_result_persist can mutate the result but is synchronous only; returning a Promise is silently dropped.

This makes it impossible to implement the secondary LLM scan pattern — the primary defense recommended by OWASP LLM01:2025 and Microsoft's published indirect prompt injection guidance — where raw external content (web search results, [1:59 PM]crawled pages) is scanned by a separate model call before the primary agent sees it. Any plugin doing security validation, content moderation, or prompt injection defense on external tool results hits this wall.

Proposed solution

Add a new async hook, e.g. transform_tool_result, with the following contract:

• Fires after tool execution completes, before the result is written to the session transcript • Async — the runtime awaits its resolution before proceeding with the transcript write • Sequential with priority ordering (same pattern as tool_result_persist) • Returns { message } to replace the result, { block: true } to suppress it entirely, or undefined to pass through unchanged • Receives the same event shape as tool_result_persist: { toolName, toolCallId, message, isSynthetic } [1:59 PM]tool_result_persist remains as-is for lightweight sync transforms. transform_tool_result is the opt-in async variant for plugins that accept the latency tradeoff.

Alternatives considered

• Use tool_result_persist synchronously — doesn't work for any validation requiring I/O (API calls, LLM scans, DB lookups). Returning a Promise is silently dropped with a warning. • Use after_tool_call for async work — fires at the right time but is void; cannot influence what gets written to transcript. • Use before_tool_call to pre-validate the query — async and capable, but fires before the tool runs so the actual result content is unavailable. Can't scan what you haven't fetched yet. • Wrap built-in tools with custom tool replacements — works in theory but requires the plugin to replicate connector auth, routing, and tool behavior. [1:59 PM]Brittle, connector-specific, and breaks when built-in tools are updated. Not a viable general pattern.

Impact

• Affected: Any plugin implementing security validation, prompt injection defense, content moderation, or data sanitization on external tool results — particularly web search and crawl tools • Severity: Blocks workflow — this entire class of plugin cannot be built correctly with the current API • Frequency: Always — architectural gap, not an edge case • Consequence: Plugins defending against indirect prompt injection (OWASP LLM01:2025 #1 risk for LLM applications) have no clean intercept point. Production deployments calling external web tools are exposed with no plugin-level mitigation path.

Evidence/examples

Confirmed from source (dist/hook-runner-global-CmW7Xsw3.js): [1:59 PM]// after_tool_call — void, result discarded async function runAfterToolCall(event, ctx) { return runVoidHook("after_tool_call", event, ctx); }

// tool_result_persist — sync enforced, Promise silently dropped if (out && typeof out.then === "function") { logger?.warn(handler returned a Promise; this hook is synchronous and the result was ignored.); continue; } Execution order confirmed from handleToolExecutionEnd() in dist/pi-embedded-BYdcxQ5A.js:

  1. Tool executes
  2. after_tool_call fired fire-and-forget (.catch() only, not awaited)
  3. tool_result_persist called synchronously inside guardedAppend on transcript write
  4. Result persisted to transcript

OWASP LLM01:2025: https://genai.owasp.org/llmrisk/llm01-prompt-injection/ Microsoft indirect prompt injection defense:OWASP Gen AI Security ProjectLLM01:2025 Prompt InjectionA Prompt Injection Vulnerability occurs when user prompts alter the LLM’s behavior or output in unintended ways. These inputs can affect the model even if they are imperceptible to humans, therefore prompt injections do not need to be human-visible/readable, as long as the content is parsed by the model. Prompt Injection vulnerabilities exist in how […]Est. reading time6 minuteshttps://genai.owasp.org/llmrisk/llm01-prompt-injection/[1:59 PM]https://www.microsoft.com/en-us/msrc/blog/2025/07/how-microsoft-defends-against-indirect-prompt-injection-attacks

Additional information

tool_result_persist is sync by design for hot-path performance (noted in source comments). The proposal preserves that default — transform_tool_result is an explicit opt-in for plugins that need async and accept the added latency. The two hooks coexist; transform_tool_result would run first (async, can replace the message), then tool_result_persist runs on whatever transform_tool_result returned (sync, lightweight final transforms).

extent analysis

TL;DR

To address the issue, add a new async plugin hook, transform_tool_result, that fires after tool execution completes and allows plugins to asynchronously validate or transform tool results before they are written to the session transcript.

Guidance

  • Implement the proposed transform_tool_result hook with the specified contract to enable async validation and transformation of tool results.
  • Ensure the new hook is sequential with priority ordering, similar to tool_result_persist, to maintain consistency in plugin execution.
  • Verify that the transform_tool_result hook is called after after_tool_call and before tool_result_persist to ensure correct execution order.
  • Test plugins using the new hook to validate its functionality and ensure it meets the required use cases, such as security validation and content moderation.

Example

// Example implementation of the transform_tool_result hook
async function transformToolResult(event, ctx) {
  // Perform async validation or transformation of the tool result
  const result = await validateResult(event.message);
  return { message: result }; // Return the modified result
}

Notes

The implementation of the transform_tool_result hook should be carefully considered to ensure it meets the performance and security requirements of the application. The hook's async nature may introduce additional latency, which should be weighed against the benefits of improved security and validation.

Recommendation

Apply the proposed workaround by implementing the transform_tool_result hook, as it provides a necessary solution for plugins that require async validation and transformation of tool results, addressing a critical security concern.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING