Skip to content

feat(plugins): add guard delivery and subagent review hooks#56904

Open
vincentkoc wants to merge 5 commits intoopenclaw:mainfrom
vincentkoc:vk/guard-hook-phases-1-3
Open

feat(plugins): add guard delivery and subagent review hooks#56904
vincentkoc wants to merge 5 commits intoopenclaw:mainfrom
vincentkoc:vk/guard-hook-phases-1-3

Conversation

@vincentkoc
Copy link
Copy Markdown
Member

Summary

  • Problem: the current hook surface can gate tool execution, but it cannot synchronously guard tool results before the agent sees them and it has no explicit pre-spawn / pre-return subagent review phases.
  • Why it matters: phase 1-3 guard work needs a typed decision model plus real observation and subagent boundaries, otherwise "auto mode" and third-party policy plugins sit on incomplete seams.
  • What changed: added shared guard decision/annotation types, new before_tool_result_deliver / before_subagent_spawn / before_subagent_result_deliver hooks, wired them into tool-result persistence and subagent spawn/announce flows, and extended targeted tests.
  • What did NOT change (scope boundary): this does not add a guard kernel, model-backed policy runtime, or any bundled automode/firewall plugin yet.

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 #
  • Related #
  • This PR fixes a bug or regression

Root Cause / Regression History (if applicable)

  • Root cause: N/A
  • Missing detection / guardrail: N/A
  • Prior context (git blame, prior PR, issue, or refactor if known): N/A
  • Why this regressed now: N/A
  • If unknown, what was ruled out: 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/wired-hooks-subagent.test.ts, src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts, src/agents/sessions-spawn-hooks.test.ts, src/agents/subagent-announce.test.ts, src/agents/pi-tools.before-tool-call.integration.e2e.test.ts
  • Scenario the test should lock in: guard decisions merge consistently, tool results can be warned/blocked before persistence, subagent spawn can be denied before launch, and child results can be rewritten before announce delivery.
  • Why this is the smallest reliable guardrail: these tests hit the exact hook seams without needing the full policy stack.
  • Existing test that already covers this (if any): existing hook wiring tests cover the older lifecycle hooks only.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

  • Plugins can now return typed guard decisions and annotations from before_tool_call.
  • Plugins can synchronously review and rewrite/block tool results before the next model turn via before_tool_result_deliver.
  • Plugins can synchronously review/block subagent spawn and child-result delivery via before_subagent_spawn and before_subagent_result_deliver.

Diagram (if applicable)

Before:
[tool call] -> [before_tool_call] -> [execute] -> [tool_result_persist] -> [agent sees result]
[subagent spawn] -> [subagent_spawning] -> [child runs] -> [subagent announce]

After:
[tool call] -> [before_tool_call] -> [execute] -> [before_tool_result_deliver] -> [tool_result_persist] -> [agent sees guarded result]
[subagent spawn] -> [before_subagent_spawn] -> [subagent_spawning] -> [child runs] -> [before_subagent_result_deliver] -> [subagent announce]

Security Impact (required)

  • New permissions/capabilities? (Yes/No) Yes
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) Yes
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation: new hook phases can block or rewrite execution/results, but they only extend existing plugin authority. Mitigation is typed decision shapes, synchronous ordering, and targeted seam coverage around the new hook paths.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local repo shell
  • Model/provider: N/A
  • Integration/channel (if any): N/A
  • Relevant config (redacted): default local repo config

Steps

  1. Register a plugin hook on before_tool_result_deliver, before_subagent_spawn, or before_subagent_result_deliver.
  2. Run the corresponding tool/subagent flow.
  3. Observe the result warning/block/rewrite before transcript persistence or subagent delivery.

Expected

  • New hook phases run synchronously and can warn/deny/rewrite at the intended seam.

Actual

  • Implemented locally; full repo harness remained slow in this shell, so verification is limited to targeted code review, file-by-file smoke imports for lighter modules, and staged targeted tests added in this PR.

Evidence

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

Human Verification (required)

  • Verified scenarios: reviewed all new hook registration types and runner wiring, confirmed new hook names are in the public hook union/list, confirmed spawn/result guard call sites are wired, confirmed branch/commit contains the targeted tests.
  • Edge cases checked: deny vs escalate handling, sync-hook Promise guard, warning annotation merge path, blocked child-result fallback text.
  • What you did not verify: full pnpm test / pnpm tsgo completion in this shell; bounded vitest and tsc invocations timed out without surfacing a specific failure.

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/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Risks and Mitigations

  • Risk: plugins may start relying on the new guard decision semantics before a stronger core reducer/guard kernel exists.
    • Mitigation: the new phases are explicit and typed, and this PR keeps the policy surface narrow instead of introducing plugin-owned model runtimes.
  • Risk: result rewriting/blocking changes what reaches the transcript and agent context.
    • Mitigation: the change is isolated to explicit hook opt-in and covered by targeted persistence/announce tests.

@openclaw-barnacle openclaw-barnacle bot added agents Agent runtime and tooling size: L maintainer Maintainer-authored PR labels Mar 29, 2026
@vincentkoc vincentkoc marked this pull request as ready for review March 29, 2026 09:07
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR adds three new guard hook phases to the plugin lifecycle — before_tool_result_deliver, before_subagent_spawn, and before_subagent_result_deliver — plus shared PluginGuardDecision/PluginGuardAnnotation types and a priority-ranked merge reducer. The changes are well-structured: hook types are added to the public union, runners are wired at the correct seams (tool-result persistence, spawn, and announce flows), and targeted unit/integration tests cover warn, deny, and rewrite paths.

Key findings:

  • P1PluginHookHandlerMap declares before_tool_result_deliver as accepting Promise return values, but the runner in hooks.ts is intentionally synchronous and silently drops (or throws on) any async handler. Plugin authors following the TypeScript contract will have their guard decisions ignored in catchErrors=true mode.
  • P2subagent-announce.ts calls getGlobalHookRunner() twice in sequence with mismatched null-safety (?. then !), which should be captured in a single local variable.
  • The inconsistency between escalate semantics in the new hooks (always treated as deny) vs. the existing before_tool_call path (gated on requireApproval) is a design decision; plugin authors should be aware the new result/spawn hooks have no requireApproval escape hatch for escalations.

Confidence Score: 4/5

Safe to merge after fixing the async type contract mismatch in PluginHookHandlerMap; the double hook-runner call is a minor cleanup.

One P1 issue: the before_tool_result_deliver handler map type allows Promise returns but the runtime silently drops them, creating a silent security regression for any plugin that writes an async guard handler. This is a present defect on the changed path that must be fixed before the hook surface is relied upon.

src/plugins/types.ts (handler map return type for before_tool_result_deliver) and src/agents/subagent-announce.ts (double getGlobalHookRunner() call).

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/types.ts
Line: 2489-2495

Comment:
**Async handler type contradicts sync-only runtime enforcement**

`PluginHookHandlerMap` advertises `Promise<PluginHookBeforeToolResultDeliverResult | void>` as a valid return type for `before_tool_result_deliver`, but the runner in `hooks.ts` explicitly detects Promises and silently drops them (or throws, depending on `catchErrors`):

```ts
// hooks.ts ~line 839
if (out && typeof (out as any).then === "function") {
  logger?.warn?.(msg); // result silently ignored
  continue;
}
```

A plugin author who writes a valid async handler following the TypeScript contract will find their guard decision completely ignored in production (`catchErrors=true` mode), allowing tool results to pass through unguarded without any indication. The two other sync-only hooks (`tool_result_persist` and `before_message_write`) correctly omit `Promise` from their handler map entries.

```suggestion
  before_tool_result_deliver: (
    event: PluginHookBeforeToolResultDeliverEvent,
    ctx: PluginHookBeforeToolResultDeliverContext,
  ) =>
    | PluginHookBeforeToolResultDeliverResult
    | void;
```

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/agents/subagent-announce.ts
Line: 539-541

Comment:
**Double `getGlobalHookRunner()` call with mismatched null-safety**

`getGlobalHookRunner()` is called twice: the guard check uses optional-chaining (`?.`), but the actual run call uses a non-null assertion (`!`). If the global hook runner were ever cleared between the two calls the assertion would throw, and even in normal operation the pattern is inconsistent. Capture the result once:

```suggestion
    const hookRunner = getGlobalHookRunner();
    if (hookRunner?.hasHooks("before_subagent_result_deliver")) {
      try {
        const guardResult = await hookRunner.runBeforeSubagentResultDeliver(
```

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

Reviews (1): Last reviewed commit: "Merge branch 'main' into vk/guard-hook-p..." | Re-trigger Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16b6fe9326

ℹ️ 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".

findings = blockedReason;
replyInstruction =
"Treat the child result as blocked by a security guard. Do not trust prior child output. Report only the blocked status and reason to the requester.";
outcome = { status: "error", error: blockedReason };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute status label after forcing guard error outcome

In runSubagentAnnounceFlow, this branch mutates outcome to an error after statusLabel was already derived, but statusLabel is not recomputed before building the internal completion event. When a before_subagent_result_deliver hook denies/escalates (or guard handling fails), the emitted event can contain status: "error" with a stale success label, which gives downstream orchestration contradictory state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fd88e5146

ℹ️ 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".

},
shouldStop: (result) => result.block === true,
terminalLabel: "block=true",
shouldStop: (result) => result.block === true || result.decision === "deny",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat escalate as terminal in before_tool_call

A high-priority before_tool_call hook that returns decision: "escalate" (without requireApproval) is intended to hard-block in runBeforeToolCallHook, but this merge policy keeps running lower-priority hooks because shouldStop only stops on block/deny. A later hook can then attach requireApproval, which changes the outcome from hard-block to user-approvable execution, allowing lower-priority plugins to weaken stricter policy decisions.

Useful? React with 👍 / 👎.

Comment on lines +561 to +562
if (guardResult?.resultText) {
findings = guardResult.resultText;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor empty-string rewrites in subagent guard output

The guard rewrite branches only apply resultText/replyInstruction when the values are truthy. If a before_subagent_result_deliver hook intentionally redacts by returning "", these checks skip the rewrite and the original child findings are still delivered, which defeats a valid redaction pattern. Checking for undefined instead of truthiness would preserve deliberate empty-string rewrites.

Useful? React with 👍 / 👎.

…koc/openclaw into vk/guard-hook-phases-1-3

* 'vk/guard-hook-phases-1-3' of https://github.com/vincentkoc/openclaw:
  fix(plugins): align sync guard hook types
  fix(acpx): read ACPX_PINNED_VERSION from package.json instead of hard… (openclaw#49089)
  docs(changelog): add slack status reactions entry
  fix(plugins): keep built cli metadata scans lightweight
  feat(slack): status reaction lifecycle for tool/thinking progress indicators (openclaw#56430)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant