Skip to content

security(gateway): route hook completion events to the target agent's session#68667

Closed
bluesky6868 wants to merge 2 commits into
openclaw:mainfrom
security-for-ai-agent:fix/security-24693-hook-completion-cross-agent-leak
Closed

security(gateway): route hook completion events to the target agent's session#68667
bluesky6868 wants to merge 2 commits into
openclaw:mainfrom
security-for-ai-agent:fix/security-24693-hook-completion-cross-agent-leak

Conversation

@bluesky6868

Copy link
Copy Markdown

Summary

  • Problem: dispatchAgentHook in src/gateway/server/hooks.ts computed the hook-completion system-event's sessionKey with resolveMainSessionKeyFromConfig(), which always returns the default agent's main session. When a hook was routed to a non-default agent via the agentId field (for example a Gmail hook with agentId: "dev"), the status/error summary — which contains the processed payload text — still landed in the default agent's session queue.
  • Why it matters: in multi-agent setups where different agents handle different data (work vs personal email, client A vs client B, etc.), this leaked hook-processed content (email bodies, webhook summaries) across the agent trust boundary and broke the isolation that the agentId field promises. Security: Hook completion events leak cross-agent email content to default agent session #24693 was the second cross-agent isolation issue found in the hook system after Hook/cron lanes resolve auth from hardcoded 'main' agent path, ignoring default agent #24016.
  • What changed: resolve the completion-event session key from value.agentId via resolveAgentMainSessionKey({ cfg, agentId }), falling back to resolveMainSessionKeyFromConfig() only when the hook did not specify an agent. Shared one computed key across the success and error code paths.
  • What did NOT change (scope boundary):
    • Dispatch semantics for the hook's actual agent turn are unchanged — runCronIsolatedAgentTurn already receives sessionKey: value.sessionKey from the request handler, and that plumbing is intact.
    • The dispatchWakeHook path (which has no notion of a target agent) still routes to the default main session.
    • resolveHookTargetAgentId (the function that normalizes unknown agent ids back to the default) is unchanged; when that normalization already rewrote an unknown agentId to "main" upstream of dispatch, the fix naturally routes completion events to the default agent too.
    • No changes to the system-event queue, enqueueSystemEvent, session key parsing, or hook HTTP request handler.

Change Type (select all)

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

(Security hardening rather than a plain bug fix because the impact is a cross-trust-boundary leak, not a functional regression.)

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

Root Cause (if applicable)

  • Root cause: dispatchAgentHook captured the default agent's main session key once, at dispatch time, via resolveMainSessionKeyFromConfig(), and reused it for the completion-event enqueue regardless of the hook's agentId. The call pre-dates the introduction of per-agent hook routing, so it never learned about value.agentId.
  • Missing detection / guardrail: there was no assertion in the existing hook tests (src/gateway/server.hooks.test.ts and src/gateway/server/hooks.agent-trust.test.ts) that checked which session the completion event landed in. The tests drained events from the default main session, which was enough to keep them green even when the event was going to the wrong destination. This PR adds that assertion.
  • Contributing context: Hook/cron lanes resolve auth from hardcoded 'main' agent path, ignoring default agent #24016 fixed the analogous leak on the inbound auth-context routing path; this fix completes the loop on the outbound system-event path.

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/gateway/server/hooks.agent-trust.test.ts — two new assertion-shape tests ("routes hook completion events to the target agent's main session, not the default agent's" and the error-path variant). They mock resolveAgentMainSessionKey to return agent:dev:main and assert that enqueueSystemEvent was called with exactly that session key, and that resolveMainSessionKeyFromConfig was not called for this path.
    • src/gateway/server.hooks.test.ts — existing integration tests (preserves target-agent prefixes before isolated dispatch, rebinds mismatched agent prefixes..., enforces hooks.allowedAgentIds for explicit agent routing, and the combined handles auth, wake, and agent flows scenario) were updated to wait for the completion event on the hooks agent's session (agent:hooks:main) and then drain from that session, locking in the new routing.
  • Scenario the test should lock in: when a hook carries agentId: "dev", the completion event is enqueued into agent:dev:main, not the default agent's session.
  • Why this is the smallest reliable guardrail: the bug was a one-line mis-routing in the dispatcher, and a unit test that mocks the session resolver and asserts the exact sessionKey argument of enqueueSystemEvent is the minimum shape that can distinguish "correct routing" from "happened not to fire an event in the wrong queue."
  • Existing test that already covers this: none — the bug was invisible to the old tests because they only drained from the default main session.
  • If no new test is added, why not: N/A, tests added.

User-visible / Behavior Changes

  • Hook completion / error system events now land in the target agent's main session. Operators who were reading hook completion summaries from the default agent's session when the hook was routed to a non-default agent (effectively relying on the bug) will see those summaries move to the correct agent's session.
  • No other behavior changes; hook turn dispatch, delivery, and heartbeat-wake behavior are identical.

Diagram (if applicable)

Before (leak — same session regardless of agentId):
  POST /hooks/agent agentId=dev → dispatchAgentHook
                                 │
                                 ├─ runCronIsolatedAgentTurn(sessionKey=<hook session>, agentId=dev)
                                 │   └─ processes email body into dev agent's context
                                 └─ enqueueSystemEvent("Hook gmail: <summary>", sessionKey = default "main")
                                                                              ↑
                                                          default agent reads summary of dev's hook payload

After (per-agent routing):
  POST /hooks/agent agentId=dev → dispatchAgentHook
                                 ├─ runCronIsolatedAgentTurn(sessionKey=<hook session>, agentId=dev)
                                 └─ enqueueSystemEvent("Hook gmail: <summary>", sessionKey = "agent:dev:main")
                                                                              ↑
                                                           only the dev agent sees the summary

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? Yes — narrowed. Hook payload summaries are now visible only to the hook's target agent, not to the default agent. This closes a cross-agent data-leak vector where non-default agents' hook-processed content (e.g. email bodies) was being announced into the default agent's session.

Repro + Verification

Environment

  • OS: Linux x86_64
  • Runtime/container: local pnpm test against the gateway vitest project
  • Model/provider: N/A
  • Integration/channel: hook HTTP endpoint (/hooks/agent)
  • Relevant config (redacted): multi-agent setup with main (default) + hooks (non-default) agents; hook token configured.

Steps

  1. pnpm test src/gateway/server/hooks.agent-trust.test.ts --run → 4/4 pass, including the two new cross-agent routing assertions.
  2. pnpm test src/gateway/server.hooks.test.ts --run → 17/17 pass, including the four integration tests that were updated to wait on the hooks agent's session key.
  3. pnpm test src/gateway/server/ --run → 87/87 pass (one-level-wider gate around the touched surface).
  4. pnpm lint src/gateway/server/hooks.ts src/gateway/server/hooks.agent-trust.test.ts src/gateway/server.hooks.test.ts src/gateway/test-helpers.server.ts → 0 warnings, 0 errors.

Expected

Hook completion events enqueue into agent:<targetAgentId>:main, not the default main session. No existing behavior regresses.

Actual

Matches expected. Note: pnpm check (full gate) currently fails on an unrelated pre-existing lint error on main (extensions/voice-call/index.test.ts:78:3 — typescript-eslint(no-meaningless-void-operator)). That error is reproducible on upstream/main HEAD with no local changes; it is not introduced by this PR and is not in the touched scope. Flagging for maintainers — happy to rebase once it's addressed.

Evidence

  • Failing test/log before + passing after — before: existing tests had no cross-agent routing assertion; after: four assertions (two new + two updated) lock in the target-agent routing.
  • Trace/log snippets — see test filenames above; Duration 15s / 87 tests passed for the widest run in scope.
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios:
    • With agentId: "dev", completion summary lands in agent:dev:main, not the default session.
    • With agentId: "hooks", completion summary lands in agent:hooks:main, confirmed via the integration tests that drain the hooks session after posting.
    • With unknown agentId, the upstream resolver (resolveHookTargetAgentId) rewrites the agent to the default before dispatch, so the completion event still lands in the default session (existing fallback test handles auth, wake, and agent flows covers this path).
    • Lint-clean on touched files.
  • Edge cases checked:
    • Error path: thrown exceptions from runCronIsolatedAgentTurn now also enqueue their error event into the target agent's session (new assertion test covers this).
    • loadConfig() is called inside the sync branch when value.agentId is set; the existing code already calls loadConfig() inside the async IIFE so this is not a new I/O hot path.
  • What I did not verify:
    • End-to-end with a live Gmail hook on a real multi-agent install. Covered by the integration test handles auth, wake, and agent flows, which mocks the isolated run and asserts on the event stream directly.

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 — the default-agent case is unchanged. Non-default-agent hook consumers only see the event move to the correct session queue; any operator workflow reading it from the wrong place was relying on the bug.
  • Config/env changes? No
  • Migration needed? No
  • If yes, exact upgrade steps: N/A

Risks and Mitigations

  • Risk: an operator workflow that was monitoring the default agent's session for hook summaries from non-default agents will stop seeing them.
    • Mitigation: the correct session (agent:<id>:main) still receives them; documented in the commit body. This is the isolation the agentId field was always meant to provide.
  • Risk: resolveAgentMainSessionKey picks a session key for an agentId that isn't configured.
    • Mitigation: the upstream request handler normalizes unknown agent ids via resolveHookTargetAgentId before dispatch, so by the time dispatchAgentHook sees value.agentId, it is either a known agent or the normalized default. Integration test handles auth, wake, and agent flows exercises the missing-agent → main normalization path and still passes.

… session

Hook completion and error system events were enqueued into the default
agent's main session via `resolveMainSessionKeyFromConfig()`, regardless of
which agent processed the hook. In a multi-agent setup where a hook is
routed to a non-default agent via `agentId` (for example a Gmail hook with
`agentId: "dev"`), the hook summary — which contains the processed payload
text — still landed in the default agent's session, breaking per-agent
isolation and leaking content (e.g. email bodies) across agent boundaries.

The dispatcher now resolves the completion session key from the hook's
target `agentId` using `resolveAgentMainSessionKey`, falling back to the
default main session only when the hook did not specify an agent. The
success and error code paths share one computed key, so both enqueue into
the same target-agent session.

Covered by new assertion tests in hooks.agent-trust.test.ts and an updated
session-routing assertion in server.hooks.test.ts that now asserts the
event lands in `agent:hooks:main`. `waitForSystemEvent` gained an optional
`{ sessionKey }` override so tests can wait on a non-default agent's queue.

Fixes openclaw#24693.
@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: S labels Apr 18, 2026
@greptile-apps

greptile-apps Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a cross-agent data-leak in dispatchAgentHook: hook completion/error events were always enqueued into the default agent's main session (via resolveMainSessionKeyFromConfig()) regardless of the hook's agentId, so non-default agents' payload summaries were visible to the default agent. The fix resolves completionSessionKey from value.agentId via resolveAgentMainSessionKey when set, falling back to resolveMainSessionKeyFromConfig() for the no-agent case. New unit tests assert exact session-key routing on both success and error paths, and the integration tests are updated to wait on and drain from the target agent's session instead of the default.

Confidence Score: 5/5

Safe to merge — the fix is correct and minimal, tests cover both paths, and no existing behavior regresses.

The only finding is a P2 style note about calling loadConfig() twice per dispatch. No logic bugs, no security regressions, and the new tests lock in the exact routing behavior for both success and error paths.

No files require special attention.

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/server/hooks.ts
Line: 53-55

Comment:
**Duplicate `loadConfig()` call per dispatch**

`loadConfig()` is called synchronously here to compute `completionSessionKey`, and then called again inside the async IIFE at line 92 for the actual agent turn. When `value.agentId` is set, every dispatch pays two config loads. If `loadConfig()` is cached this is harmless, but the two snapshots could theoretically diverge if config is reloaded between the synchronous and async phases. Lifting one `loadConfig()` call above both uses would make this explicit and avoid the duplication.

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

Reviews (1): Last reviewed commit: "security(gateway): route hook completion..." | Re-trigger Greptile

Comment thread src/gateway/server/hooks.ts Outdated
Comment on lines +53 to +55
const completionSessionKey = value.agentId
? resolveAgentMainSessionKey({ cfg: loadConfig(), agentId: value.agentId })
: resolveMainSessionKeyFromConfig();

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.

P2 Duplicate loadConfig() call per dispatch

loadConfig() is called synchronously here to compute completionSessionKey, and then called again inside the async IIFE at line 92 for the actual agent turn. When value.agentId is set, every dispatch pays two config loads. If loadConfig() is cached this is harmless, but the two snapshots could theoretically diverge if config is reloaded between the synchronous and async phases. Lifting one loadConfig() call above both uses would make this explicit and avoid the duplication.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server/hooks.ts
Line: 53-55

Comment:
**Duplicate `loadConfig()` call per dispatch**

`loadConfig()` is called synchronously here to compute `completionSessionKey`, and then called again inside the async IIFE at line 92 for the actual agent turn. When `value.agentId` is set, every dispatch pays two config loads. If `loadConfig()` is cached this is harmless, but the two snapshots could theoretically diverge if config is reloaded between the synchronous and async phases. Lifting one `loadConfig()` call above both uses would make this explicit and avoid the duplication.

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

Fix in Claude Code

Address Greptile P2 style finding on openclaw#68667: lift loadConfig() out of the
async IIFE so both the synchronous completion-session-key resolution and
the async isolated agent turn see the same config snapshot, removing the
double load when `value.agentId` is set and any theoretical drift between
the two snapshots. The fallback path (no agentId) switches from
`resolveMainSessionKeyFromConfig()` to `resolveMainSessionKey(cfg)` so
both branches share `cfg`. Test mock updated to cover the new import.
@bluesky6868

Copy link
Copy Markdown
Author

Addressed in df65e0d: lifted loadConfig() to the top of dispatchAgentHook so both the sync completion-session-key resolution and the async isolated-agent turn share one cfg snapshot. No more duplicate load, no cross-call drift risk. Pushed as a separate commit for delta review; will squash before the final merge handoff.

@vincentkoc

Copy link
Copy Markdown
Member

ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.

Replacement PR: #73228
Source PR: #68667
Contributor credit is preserved in the replacement PR body and changelog plan.

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

Labels

gateway Gateway runtime size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: Hook completion events leak cross-agent email content to default agent session

2 participants