fix(plugins): harden tool result middleware#71241
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Tool result provenance bypass via in-place mutation in tool-result middleware pipeline
Description
Impact:
Vulnerable flow:
RecommendationTreat tool results as immutable across middleware boundaries. Options:
Example fix (clone before middleware + keep snapshot): const rawResult = await tool.execute(...);
const rawSnapshot = structuredClone(rawResult);
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
...,
// pass a clone so handlers can't mutate the snapshot/raw
result: structuredClone(rawResult),
});
collectToolTelemetry({
...,
result: middlewareResult,
mediaTrustResult: rawSnapshot,
});Also consider updating the middleware runner to defensively clone 2. 🟠 Unbounded aggregate tool result payload allows memory/CPU DoS via middleware output (text/image blocks + unvalidated details)
Description
Impact (attacker-controlled plugin / compromised middleware):
Downstream amplification example:
Vulnerable code: const MAX_MIDDLEWARE_CONTENT_BLOCKS = 200;
const MAX_MIDDLEWARE_TEXT_CHARS = 100_000;
const MAX_MIDDLEWARE_IMAGE_DATA_CHARS = 5_000_000;
...
return value.content.every(isValidMiddlewareContentBlock);
// NOTE: no validation / size cap for `value.details`RecommendationAdd defense-in-depth validation for the entire tool result, including Suggested approach:
Example (sketch): const MAX_TOTAL_TEXT_CHARS = 200_000;
const MAX_TOTAL_IMAGE_DATA_CHARS = 5_000_000; // total, not per-block
const MAX_DETAILS_JSON_CHARS = 50_000;
function safeJsonSize(value: unknown, maxChars: number): boolean {
try {
return JSON.stringify(value).length <= maxChars;
} catch {
return false;
}
}
function isValidMiddlewareToolResult(value: unknown): value is OpenClawAgentToolResult {
if (!isRecord(value) || !Array.isArray(value.content)) return false;
if (value.content.length > MAX_MIDDLEWARE_CONTENT_BLOCKS) return false;
let totalText = 0;
let totalImage = 0;
for (const block of value.content) {
if (!isValidMiddlewareContentBlock(block)) return false;
if (block.type === "text") totalText += block.text.length;
if (block.type === "image") totalImage += block.data.length;
if (totalText > MAX_TOTAL_TEXT_CHARS || totalImage > MAX_TOTAL_IMAGE_DATA_CHARS) return false;
}
// either drop `details` or strictly bound it
if ("details" in value && !safeJsonSize((value as any).details, MAX_DETAILS_JSON_CHARS)) return false;
return true;
}Analyzed PR: #71241 at commit Last updated on: 2026-04-24T20:15:06Z |
Greptile SummaryThis PR hardens the agent tool-result middleware seam: restricts registration to bundled plugins, makes the runner fail closed on middleware exceptions (returning a safe error result instead of raw output), validates rewritten result shapes and sizes, generates unique per-call IDs for Pi events with missing Three P2 observations worth noting:
Confidence Score: 5/5Safe to merge; all remaining findings are P2 quality/hardening observations that do not block correctness. The bundled-only guard, fail-closed error handling, and raw-provenance media trust fixes are all correct. The three P2 gaps are non-blocking: middleware is already restricted to trusted bundled plugins, so the attack surface for the validation gaps is minimal, and the isToolResultError concern is about telemetry accuracy rather than correctness. extensions/codex/src/app-server/dynamic-tools.ts (isToolResultError allowlist) and src/agents/harness/tool-result-middleware.ts (details size bound, in-place mutation note).
|
| function isValidMiddlewareToolResult(value: unknown): value is OpenClawAgentToolResult { | ||
| if (!isRecord(value) || !Array.isArray(value.content)) { | ||
| return false; | ||
| } | ||
| if (value.content.length > MAX_MIDDLEWARE_CONTENT_BLOCKS) { | ||
| return false; | ||
| } | ||
| return value.content.every(isValidMiddlewareContentBlock); | ||
| } |
There was a problem hiding this comment.
details field has no size bound in result validation
isValidMiddlewareToolResult validates the content array (count + per-block size), but the details field is not constrained at all. A bundled middleware could return an arbitrarily large or deeply nested details object and it would pass validation, be forwarded to collectToolTelemetry, and potentially serialized downstream. Since the validation goal is to bound memory impact, adding a rough byte-budget check or a depth/key-count guard on details would close this gap.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/harness/tool-result-middleware.ts
Line: 38-46
Comment:
**`details` field has no size bound in result validation**
`isValidMiddlewareToolResult` validates the `content` array (count + per-block size), but the `details` field is not constrained at all. A bundled middleware could return an arbitrarily large or deeply nested `details` object and it would pass validation, be forwarded to `collectToolTelemetry`, and potentially serialized downstream. Since the validation goal is to bound memory impact, adding a rough byte-budget check or a depth/key-count guard on `details` would close this gap.
How can I resolve this? If you propose a fix, please make it concise.| try { | ||
| const next = await handler({ ...event, result: current }, ctx); | ||
| if (next?.result) { | ||
| current = next.result; | ||
| if (isValidMiddlewareToolResult(next.result)) { | ||
| current = next.result; | ||
| } else { | ||
| log.warn( | ||
| `[${ctx.harness}] discarded invalid tool result middleware output for ${truncateUtf16Safe( | ||
| event.toolName, | ||
| 120, | ||
| )}`, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
In-place mutations of
event.result bypass shape/size validation
The handler receives { ...event, result: current }, where result is a reference to current. If a middleware mutates event.result.content in place and returns undefined, next?.result is falsy so isValidMiddlewareToolResult is never called — but current reflects the mutation. The Pi bridge test deliberately exercises this pattern, so this is intentional for backwards compatibility, but it means the MAX_MIDDLEWARE_CONTENT_BLOCKS / MAX_MIDDLEWARE_TEXT_CHARS limits cannot be enforced against in-place mutations. A comment documenting the design choice would prevent future confusion.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/harness/tool-result-middleware.ts
Line: 73-86
Comment:
**In-place mutations of `event.result` bypass shape/size validation**
The handler receives `{ ...event, result: current }`, where `result` is a reference to `current`. If a middleware mutates `event.result.content` in place and returns `undefined`, `next?.result` is falsy so `isValidMiddlewareToolResult` is never called — but `current` reflects the mutation. The Pi bridge test deliberately exercises this pattern, so this is intentional for backwards compatibility, but it means the `MAX_MIDDLEWARE_CONTENT_BLOCKS` / `MAX_MIDDLEWARE_TEXT_CHARS` limits cannot be enforced against in-place mutations. A comment documenting the design choice would prevent future confusion.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 97c4877b23
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }, | ||
| ], | ||
| details: { | ||
| status: "failed", |
There was a problem hiding this comment.
Use canonical error status for middleware failure result
The fail-closed fallback currently sets details.status to "failed", but Pi run processing only classifies tool-result failures when status is "error" or "timeout" (see isToolResultError in src/agents/pi-embedded-subscribe.tools.ts). When middleware throws in Pi, this synthetic result is therefore treated as a successful tool completion, so downstream error-handling paths (like failure bookkeeping and guarded side-effect handling) are skipped even though the tool output was replaced due to post-processing failure.
Useful? React with 👍 / 👎.
Summary
registerAgentToolResultMiddleware(...)to bundled plugins and keeps captured registrations' harness metadata.toolCallIds, propagates Codex raw error state, and anchors Codex media trust checks to the raw tool result provenance.Review feedback covered
toolName.isErrorfor failed raw tool results.Validation
pnpm test:serial src/agents/harness/tool-result-middleware.test.ts src/agents/codex-app-server.extensions.test.ts src/agents/pi-embedded-runner.extensions.test.ts extensions/codex/src/app-server/dynamic-tools.test.ts src/plugins/captured-registration.test.tspnpm plugin-sdk:api:checkgit diff --checkpnpm check:changedNotes
pnpm docs:liststill reports the pre-existing missing front matter ondocs/AGENTS.md; the docs inventory itself completed.