Skip to content

feat(plugins): add before agent finalize hook#71765

Merged
vincentkoc merged 1 commit into
mainfrom
codex/before-agent-finalize-hook
Apr 26, 2026
Merged

feat(plugins): add before agent finalize hook#71765
vincentkoc merged 1 commit into
mainfrom
codex/before-agent-finalize-hook

Conversation

@vincentkoc

Copy link
Copy Markdown
Member

Summary

  • Add a typed before_agent_finalize plugin hook that runs at the natural final-answer boundary.
  • Wire the Codex native hook relay so Codex Stop maps into OpenClaw before_agent_finalize.
  • Document the distinction from /stop: /stop remains user cancellation / command lifecycle, while before_agent_finalize is a final-answer review gate.

Behavior

  • Hook results support continue, revise, and finalize.
  • Codex Stop decision: block maps to OpenClaw revise.
  • Codex Stop continue: false maps to OpenClaw finalize.
  • Non-bundled plugins need allowConversationAccess for this hook because it can receive final assistant content.

Testing

  • pnpm docs:list
  • OPENCLAW_LOCAL_CHECK_MODE=throttled pnpm test:serial src/plugins/hooks.before-agent-finalize.test.ts src/agents/harness/native-hook-relay.test.ts src/cli/native-hook-relay-cli.test.ts extensions/codex/src/app-server/native-hook-relay.test.ts extensions/codex/src/app-server/run-attempt.test.ts src/plugins/loader.test.ts src/config/schema.help.quality.test.ts
  • OPENCLAW_LOCAL_CHECK_MODE=throttled pnpm check:changed passed before the final rebase. After rebasing again, the changed gate passed typecheck/lint/import-cycle guards, then broad Vitest project sweeps repeatedly hung idle without assertion failures and were interrupted. Targeted serial coverage above passed post-rebase.

Notes

  • No screenshots: non-UI plugin/runtime/docs change.
  • AI-assisted: yes. I understand the code and the validation caveat above.

@vincentkoc vincentkoc self-assigned this Apr 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation gateway Gateway runtime cli CLI command changes agents Agent runtime and tooling extensions: codex size: M maintainer Maintainer-authored PR labels Apr 25, 2026

@fabianwilliams fabianwilliams left a comment

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.

Excellent shape — before_agent_finalize slots in cleanly between the natural final answer and the harness commit, with a clear three-state result (revise / finalize / continue). Documentation is genuinely solid: hooks.md, plugin.md, configuration-reference.md, codex-harness.md, and acp-agents.md all updated coherently, plus the Codex Stop relay path is described in the v1 support contract. Schema baseline regenerated. Conversation-access gating mirrors existing llm_input/llm_output/agent_end policy, which is the right precedent. LGTM — directly useful for plugin-driven final-answer review patterns.

@fabianwilliams

Copy link
Copy Markdown
Contributor

@vincentkoc — this PR is excellent in shape (approved on the diff content above). It's currently marked draft, so I can't merge it yet. Please mark it ready for review when you're confident the implementation is final and CI is green, and I'll squash + merge.

(Per @pSteiny's fix-and-merge directive, I'd just toggle it myself but only the author can mark a PR ready for review.)

@vincentkoc

Copy link
Copy Markdown
Member Author

Your going to spam jail. You cant approve PRs

@vincentkoc vincentkoc force-pushed the codex/before-agent-finalize-hook branch from 08d26c2 to 157d7b6 Compare April 26, 2026 00:19
@vincentkoc vincentkoc marked this pull request as ready for review April 26, 2026 00:20
@aisle-research-bot

aisle-research-bot Bot commented Apr 26, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Lower-priority plugin can force finalization by overriding earlier revise decision in before_agent_finalize merge
2 🟡 Medium Non-bundled plugins with allowConversationAccess can control agent finalization via before_agent_finalize hook
3 🟡 Medium Unbounded finalization-revision loop via before_agent_finalize hook can cause denial of service
1. 🟠 Lower-priority plugin can force finalization by overriding earlier revise decision in before_agent_finalize merge
Property Value
Severity High
CWE CWE-284
Location src/plugins/hooks.ts:260-286

Description

The before_agent_finalize hook is designed to let plugins request an additional model pass (revise) or accept the natural final answer. However, the merge logic in mergeBeforeAgentFinalize allows a later hook result with action: "finalize" to override an earlier action: "revise".

Because hooks are executed in priority order (higher priority first), this means a lower-priority plugin can negate a higher-priority plugin’s attempt to block finalization.

Implications:

  • A compliance/safety plugin can return { action: "revise" } to require additional checks.
  • Another plugin (including a compromised plugin) can return { action: "finalize" } and force acceptance of the natural final answer, bypassing the intended gate.
  • This is especially risky because before_agent_finalize is treated as a conversation hook and is available to bundled plugins by default, and to non-bundled plugins when allowConversationAccess is enabled.

Vulnerable code:

if (next.action === "finalize") {
  return { action: "finalize", reason: next.reason };
}
...
if (acc?.action === "revise") {
  return acc;
}

This explicitly gives finalize from a later hook precedence over an earlier revise, regardless of priority.

Recommendation

Make merge semantics respect priority and prevent lower-priority handlers from overriding a higher-priority revise.

Two viable approaches:

  1. “Most restrictive wins”: once any hook requests revise, keep revise (unless a trusted/core-only handler is allowed to force finalize).
const mergeBeforeAgentFinalize = (acc, next) => {
  if (acc?.action === "revise" || next.action === "revise") {
    return {
      action: "revise",
      reason: concatOptionalTextSegments({ left: acc?.reason, right: next.reason }),
    };
  }// Only accept finalize if no one requested revise.
  if (acc?.action === "finalize" || next.action === "finalize") {
    return { action: "finalize", reason: acc?.reason ?? next.reason };
  }
  return { action: "continue", reason: acc?.reason ?? next.reason };
};
  1. Restrict finalize to trusted sources: enforce (at registration time) that only bundled/core plugins can return finalize, or introduce a separate hook for core-only force-finalization. This prevents third-party plugins from bypassing safety/compliance plugins.

Whichever model you choose, document it clearly so plugin authors understand whether finalize is an override and who is allowed to use it.

2. 🟡 Non-bundled plugins with allowConversationAccess can control agent finalization via before_agent_finalize hook
Property Value
Severity Medium
CWE CWE-284
Location src/plugins/registry.ts:1337-1359

Description

The new typed hook before_agent_finalize is classified as a CONVERSATION_HOOK_NAME and is therefore gated only by plugins.entries.<id>.hooks.allowConversationAccess for non-bundled plugins.

This creates a capability escalation: a configuration flag that is documented/understood as allowing reading conversation content also enables run-control actions:

  • { action: "revise" } can request additional model passes (cost / DoS potential if repeated each finalize attempt)
  • { action: "finalize" } can override earlier revise decisions from other hooks (integrity/safety control bypass)

Because the registry does not distinguish between read-only conversation access and control over termination semantics, any non-bundled plugin that is granted allowConversationAccess=true can influence finalization behavior.

Vulnerable code (permission check treats all conversation hooks the same):

if (isConversationHookName(hookName)) {
  const explicitConversationAccess = policy?.allowConversationAccess;
  if (record.origin !== "bundled" && explicitConversationAccess !== true) {// ... block
    return;
  }
}

Recommendation

Introduce a separate explicit trust gate for finalization control (and potentially any hook that can trigger additional model work or override safety decisions).

For example:

  1. Add a new policy flag, e.g. plugins.entries.<id>.hooks.allowFinalizeControl (default false for non-bundled plugins).
  2. Enforce it specifically for before_agent_finalize registration.
if (hookName === "before_agent_finalize") {
  if (record.origin !== "bundled" && policy?.allowFinalizeControl !== true) {
    pushDiagnostic({
      level: "warn",
      pluginId: record.id,
      source: record.source,
      message:
        `typed hook "${hookName}" blocked because non-bundled plugins must set ` +
        `plugins.entries.${record.id}.hooks.allowFinalizeControl=true`,
    });
    return;
  }
}

Also consider adding a harness-side guard to prevent infinite revise loops (e.g., max revise count per turn/run).

3. 🟡 Unbounded finalization-revision loop via before_agent_finalize hook can cause denial of service
Property Value
Severity Medium
CWE CWE-400
Location src/agents/harness/native-hook-relay.ts:507-545

Description

The new before_agent_finalize hook allows plugins to block finalization by returning { action: "revise" }. In the Codex native hook relay path this is translated into a Codex Stop-hook response { decision: "block" }, which requests another model pass.

There is no guardrail in this code to limit how many times a plugin can request revision for the same run/turn:

  • input: plugin-controlled before_agent_finalize return value (via runAgentHarnessBeforeAgentFinalizeHook)
  • sink: returning decision: "block" to the Codex Stop hook (renderBeforeAgentFinalizeReviseResponse), which can trigger another model pass
  • missing: per-turn/per-run maximum revise attempts, backoff, or a “revise already used” latch

A buggy or malicious plugin can repeatedly return revise, potentially causing an infinite loop of model calls, runaway token spend, or a stuck session (denial of service / cost amplification). The stopHookActive flag is forwarded to plugins, but it is not enforced and does not prevent repeated blocks.

Recommendation

Introduce a strict cap on revision cycles per run/turn, and fail open (continue/finalize) once exceeded.

Example approach (conceptual):

// Track per (runId, turnId) revision count in memory (or session state).
const key = `${registration.runId}:${invocation.turnId ?? ""}`;
const count = (reviseCounts.get(key) ?? 0) + 1;
reviseCounts.set(key, count);

const MAX_REVISES = 1; // or small number
if (outcome.action === "revise" && count > MAX_REVISES) {
  log.warn(`before_agent_finalize revise cap exceeded; allowing finalize`, { key, count });
  return adapter.renderNoopResponse(invocation.event); // or renderStopResponse
}

Additionally:

  • Consider exponential backoff or cooldown before allowing another revise.
  • Consider automatically treating stopHookActive === true as “revise already used” unless explicitly configured.
  • Add metrics/logging for repeated revise decisions to detect plugin misbehavior early.

Analyzed PR: #71765 at commit ca7de3e

Last updated on: 2026-04-26T00:30:23Z

@vincentkoc vincentkoc force-pushed the codex/before-agent-finalize-hook branch from 157d7b6 to ca7de3e Compare April 26, 2026 00:21
@vincentkoc vincentkoc merged commit f3accc7 into main Apr 26, 2026
11 of 12 checks passed
@vincentkoc vincentkoc deleted the codex/before-agent-finalize-hook branch April 26, 2026 00:21
@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a typed before_agent_finalize plugin hook that fires at the natural final-answer boundary and wires Codex Stop events into it via the native hook relay, allowing plugins to request one more model pass before finalization. The relay plumbing, Codex config generation, and documentation updates are well-executed, but there is a silent-failure edge case in the hook normalization layer worth addressing before shipping to plugin authors.

  • Silent revise drop (lifecycle-hook-helpers.ts line 92): { action: \"revise\" } returned without a reason is quietly converted to { action: \"continue\" } with no warning. Plugin authors who omit the reason string will believe the gate is blocking finalization when the harness is proceeding normally.
  • Root cause in the type definition (hook-types.ts line 274): PluginHookBeforeAgentFinalizeResult is a flat object with both fields optional, so the compiler cannot enforce that revise always carries a reason. A discriminated union would catch this at authoring time and make the silent-downgrade impossible.

Confidence Score: 3/5

Safe to merge for infrastructure changes, but the silent revise→continue conversion is a real API footgun that will silently break plugin authors who call { action: "revise" } without a reason

Two P1 findings form a cohesive root-cause pair: the type allows { action: "revise" } without a reason, and the normalizer silently converts such requests to continue with no log warning. Fixing the discriminated union type in hook-types.ts would prevent the silent downgrade and resolve both. No security or data-loss risk, but the plugin API contract is misleading as shipped.

src/plugins/hook-types.ts (PluginHookBeforeAgentFinalizeResult type) and src/agents/harness/lifecycle-hook-helpers.ts (normalizeBeforeAgentFinalizeResult)

Comments Outside Diff (1)

  1. src/plugins/hooks.ts, line 933-960 (link)

    P2 mergeBeforeAgentFinalize propagates revise with an undefined reason

    When a single plugin returns { action: "revise" } (no reason), the merge returns { action: "revise", reason: undefined }. That bubbles into normalizeBeforeAgentFinalizeResult and is silently converted to continue. This is a second place the intent is lost — if the discriminated-union type change above is adopted, the TypeScript compiler will flag this path and the merge function can be updated consistently.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/plugins/hooks.ts
    Line: 933-960
    
    Comment:
    **`mergeBeforeAgentFinalize` propagates `revise` with an `undefined` reason**
    
    When a single plugin returns `{ action: "revise" }` (no reason), the merge returns `{ action: "revise", reason: undefined }`. That bubbles into `normalizeBeforeAgentFinalizeResult` and is silently converted to `continue`. This is a second place the intent is lost — if the discriminated-union type change above is adopted, the TypeScript compiler will flag this path and the merge function can be updated consistently.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/harness/lifecycle-hook-helpers.ts
Line: 90-92

Comment:
**`revise` silently downgraded to `continue` when `reason` is absent**

`PluginHookBeforeAgentFinalizeResult.reason` is typed as `string | undefined`, so `{ action: "revise" }` is a valid plugin return value. However, the normaliser converts that to `{ action: "continue" }` with no log warning, silently discarding the revision request. A plugin author who omits the reason string will believe the gate is blocking finalization while the harness proceeds as if no hook fired. Either make `reason` required in the `revise` discriminant, or log a warning and still honour the `revise` intent (e.g. use a fallback string).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/hook-types.ts
Line: 268-276

Comment:
**Flat optional type allows `revise` without `reason`, contradicting runtime behaviour**

`PluginHookBeforeAgentFinalizeResult` is a single object with both fields optional, but `normalizeBeforeAgentFinalizeResult` drops any `revise` that lacks a `reason`. Encoding this as a discriminated union would push the contract failure to compile-time and remove the silent-downgrade risk:

```ts
export type PluginHookBeforeAgentFinalizeResult =
  | { action?: "continue"; reason?: string }
  | { action: "revise"; reason: string }      // reason required
  | { action: "finalize"; reason?: string };
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 933-960

Comment:
**`mergeBeforeAgentFinalize` propagates `revise` with an `undefined` reason**

When a single plugin returns `{ action: "revise" }` (no reason), the merge returns `{ action: "revise", reason: undefined }`. That bubbles into `normalizeBeforeAgentFinalizeResult` and is silently converted to `continue`. This is a second place the intent is lost — if the discriminated-union type change above is adopted, the TypeScript compiler will flag this path and the merge function can be updated consistently.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plugins): add before agent finalize..." | Re-trigger Greptile

Comment on lines +90 to +92
if (result?.action === "revise") {
const reason = result.reason?.trim();
return reason ? { action: "revise", reason } : { action: "continue" };

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.

P1 revise silently downgraded to continue when reason is absent

PluginHookBeforeAgentFinalizeResult.reason is typed as string | undefined, so { action: "revise" } is a valid plugin return value. However, the normaliser converts that to { action: "continue" } with no log warning, silently discarding the revision request. A plugin author who omits the reason string will believe the gate is blocking finalization while the harness proceeds as if no hook fired. Either make reason required in the revise discriminant, or log a warning and still honour the revise intent (e.g. use a fallback string).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/harness/lifecycle-hook-helpers.ts
Line: 90-92

Comment:
**`revise` silently downgraded to `continue` when `reason` is absent**

`PluginHookBeforeAgentFinalizeResult.reason` is typed as `string | undefined`, so `{ action: "revise" }` is a valid plugin return value. However, the normaliser converts that to `{ action: "continue" }` with no log warning, silently discarding the revision request. A plugin author who omits the reason string will believe the gate is blocking finalization while the harness proceeds as if no hook fired. Either make `reason` required in the `revise` discriminant, or log a warning and still honour the `revise` intent (e.g. use a fallback string).

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/plugins/hook-types.ts
Comment on lines +268 to +276
export type PluginHookBeforeAgentFinalizeResult = {
/**
* continue: accept normal finalization.
* revise: block finalization and ask the harness for another model pass.
* finalize: force finalization even if another hook requested revision.
*/
action?: "continue" | "revise" | "finalize";
reason?: string;
};

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.

P1 Flat optional type allows revise without reason, contradicting runtime behaviour

PluginHookBeforeAgentFinalizeResult is a single object with both fields optional, but normalizeBeforeAgentFinalizeResult drops any revise that lacks a reason. Encoding this as a discriminated union would push the contract failure to compile-time and remove the silent-downgrade risk:

export type PluginHookBeforeAgentFinalizeResult =
  | { action?: "continue"; reason?: string }
  | { action: "revise"; reason: string }      // reason required
  | { action: "finalize"; reason?: string };
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hook-types.ts
Line: 268-276

Comment:
**Flat optional type allows `revise` without `reason`, contradicting runtime behaviour**

`PluginHookBeforeAgentFinalizeResult` is a single object with both fields optional, but `normalizeBeforeAgentFinalizeResult` drops any `revise` that lacks a `reason`. Encoding this as a discriminated union would push the contract failure to compile-time and remove the silent-downgrade risk:

```ts
export type PluginHookBeforeAgentFinalizeResult =
  | { action?: "continue"; reason?: string }
  | { action: "revise"; reason: string }      // reason required
  | { action: "finalize"; reason?: string };
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling cli CLI command changes docs Improvements or additions to documentation extensions: codex gateway Gateway runtime maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants