security(gateway): route hook completion events to the target agent's session#68667
Conversation
… 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.
Greptile SummaryThis PR fixes a cross-agent data-leak in Confidence Score: 5/5Safe 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. Prompt To Fix All With AIThis 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 |
| const completionSessionKey = value.agentId | ||
| ? resolveAgentMainSessionKey({ cfg: loadConfig(), agentId: value.agentId }) | ||
| : resolveMainSessionKeyFromConfig(); |
There was a problem hiding this 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.
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.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.
|
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. |
Summary
dispatchAgentHookinsrc/gateway/server/hooks.tscomputed the hook-completion system-event'ssessionKeywithresolveMainSessionKeyFromConfig(), which always returns the default agent's main session. When a hook was routed to a non-default agent via theagentIdfield (for example a Gmail hook withagentId: "dev"), the status/error summary — which contains the processed payload text — still landed in the default agent's session queue.agentIdfield 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.value.agentIdviaresolveAgentMainSessionKey({ cfg, agentId }), falling back toresolveMainSessionKeyFromConfig()only when the hook did not specify an agent. Shared one computed key across the success and error code paths.runCronIsolatedAgentTurnalready receivessessionKey: value.sessionKeyfrom the request handler, and that plumbing is intact.dispatchWakeHookpath (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 unknownagentIdto"main"upstream of dispatch, the fix naturally routes completion events to the default agent too.enqueueSystemEvent, session key parsing, or hook HTTP request handler.Change Type (select all)
(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)
Linked Issue/PR
Root Cause (if applicable)
dispatchAgentHookcaptured the default agent's main session key once, at dispatch time, viaresolveMainSessionKeyFromConfig(), and reused it for the completion-event enqueue regardless of the hook'sagentId. The call pre-dates the introduction of per-agent hook routing, so it never learned aboutvalue.agentId.src/gateway/server.hooks.test.tsandsrc/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.Regression Test Plan (if applicable)
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 mockresolveAgentMainSessionKeyto returnagent:dev:mainand assert thatenqueueSystemEventwas called with exactly that session key, and thatresolveMainSessionKeyFromConfigwas 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 combinedhandles auth, wake, and agent flowsscenario) 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.agentId: "dev", the completion event is enqueued intoagent:dev:main, not the default agent's session.sessionKeyargument ofenqueueSystemEventis the minimum shape that can distinguish "correct routing" from "happened not to fire an event in the wrong queue."User-visible / Behavior Changes
Diagram (if applicable)
Security Impact (required)
Repro + Verification
Environment
pnpm testagainst the gateway vitest project/hooks/agent)main(default) +hooks(non-default) agents; hook token configured.Steps
pnpm test src/gateway/server/hooks.agent-trust.test.ts --run→ 4/4 pass, including the two new cross-agent routing assertions.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.pnpm test src/gateway/server/ --run→ 87/87 pass (one-level-wider gate around the touched surface).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 onmain(extensions/voice-call/index.test.ts:78:3 — typescript-eslint(no-meaningless-void-operator)). That error is reproducible onupstream/mainHEAD 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
Duration 15s / 87 tests passedfor the widest run in scope.Human Verification (required)
agentId: "dev", completion summary lands inagent:dev:main, not the default session.agentId: "hooks", completion summary lands inagent:hooks:main, confirmed via the integration tests that drain the hooks session after posting.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 testhandles auth, wake, and agent flowscovers this path).runCronIsolatedAgentTurnnow also enqueue their error event into the target agent's session (new assertion test covers this).loadConfig()is called inside the sync branch whenvalue.agentIdis set; the existing code already callsloadConfig()inside the async IIFE so this is not a new I/O hot path.handles auth, wake, and agent flows, which mocks the isolated run and asserts on the event stream directly.Review Conversations
Compatibility / Migration
Risks and Mitigations
agent:<id>:main) still receives them; documented in the commit body. This is the isolation theagentIdfield was always meant to provide.resolveAgentMainSessionKeypicks a session key for anagentIdthat isn't configured.resolveHookTargetAgentIdbefore dispatch, so by the timedispatchAgentHookseesvalue.agentId, it is either a known agent or the normalized default. Integration testhandles auth, wake, and agent flowsexercises themissing-agent → mainnormalization path and still passes.