Skip to content

Commit e72621e

Browse files
authored
fix(hooks): enforce default hook agent allowlist
Enforce hook allowedAgentIds against the effective default agent when hook payloads omit or blank agentId, while preserving omitted-agent dispatch semantics for default/global routing. Also updates the affected generated hook config docs from the contributor change and fixes the current-main memory-core test mock after rebasing the PR branch. Verification: - pnpm format:check extensions/memory-core/src/dreaming.test.ts src/gateway/hooks.ts src/gateway/hooks.test.ts src/gateway/server/hooks-request-handler.ts src/gateway/server.hooks.test.ts && git diff --check - node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-server.config.ts src/gateway/hooks.test.ts src/gateway/server.hooks.test.ts --reporter=dot --pool=forks --no-file-parallelism --testTimeout=120000 - node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test-local-pr87124.tsbuildinfo - pnpm check:test-types - .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main - GitHub PR merge state CLEAN; CodeQL Critical Quality rerun succeeded after first runner checkout wedged Co-authored-by: Agustin Rivera <[email protected]>
1 parent 2814ab6 commit e72621e

11 files changed

Lines changed: 133 additions & 35 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
bd43ed4d839351337f16e151572b2eb22d62eabd007cab0d43feab355ef36dcd config-baseline.json
2-
cd9a23799e2c04158253a60b79afd9c098db1ad615aab56eeebbed28e492ce14 config-baseline.core.json
1+
53b7621e99d75b98ecc8f4389d38900f84cf213f95dbcc877f36125d763c660d config-baseline.json
2+
e92bbf45714e418383118098d4ff15d347fa8ffc7e7837b437b522d2b59ce9fe config-baseline.core.json
33
b901fb766edfd9df630690281476fc4032c64772f69d1d8f7b2e0e913a90f229 config-baseline.channel.json
44
5c214ab364011fd95735755f9fa4298aa4de8ad81144ae8dd08d969bb7ba318b config-baseline.plugin.json

docs/automation/cron-jobs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
306306

307307
- Use a dedicated hook token; do not reuse gateway auth tokens.
308308
- Keep `hooks.path` on a dedicated subpath; `/` is rejected.
309-
- Set `hooks.allowedAgentIds` to limit explicit `agentId` routing.
309+
- Set `hooks.allowedAgentIds` to limit which effective agent a hook can target, including the default agent when `agentId` is omitted.
310310
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
311311
- If you enable `hooks.allowRequestSessionKey`, also set `hooks.allowedSessionKeyPrefixes` to constrain allowed session key shapes.
312312
- Hook payloads are wrapped with safety boundaries by default.

docs/gateway/configuration-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -709,8 +709,8 @@ Validation and safety notes:
709709
- `transform` can point to a JS/TS module returning a hook action.
710710
- `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
711711
- Keep `hooks.transformsDir` under `~/.openclaw/hooks/transforms`; workspace skill directories are rejected. If `openclaw doctor` reports this path as invalid, move the transform module into the hooks transforms directory or remove `hooks.transformsDir`.
712-
- `agentId` routes to a specific agent; unknown IDs fall back to default.
713-
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
712+
- `agentId` routes to a specific agent; unknown IDs fall back to the default agent.
713+
- `allowedAgentIds`: restricts effective agent routing, including the default-agent path when `agentId` is omitted (`*` or omitted = allow all, `[]` = deny all).
714714
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
715715
- `allowRequestSessionKey`: allow `/hooks/agent` callers and template-driven mapping session keys to set `sessionKey` (default: `false`).
716716
- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. It becomes required when any mapping or preset uses a templated `sessionKey`.

extensions/memory-core/src/dreaming.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2267,11 +2267,15 @@ describe("short-term dreaming trigger", () => {
22672267
],
22682268
});
22692269

2270+
const narrativeMessages = [{ role: "assistant", content: "A diary entry." }];
22702271
const subagent = {
22712272
run: vi.fn(async (_params: { model?: string }) => ({ runId: "narrative-run-1" })),
2272-
waitForRun: vi.fn(async () => ({ status: "ok" })),
2273+
waitForRun: vi.fn(async () => ({ status: "ok" as const })),
22732274
getSessionMessages: vi.fn(async () => ({
2274-
messages: [{ role: "assistant", content: "A diary entry." }],
2275+
messages: narrativeMessages,
2276+
})),
2277+
getSession: vi.fn(async () => ({
2278+
messages: narrativeMessages,
22752279
})),
22762280
deleteSession: vi.fn(async () => {}),
22772281
};

src/config/schema.help.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1743,7 +1743,7 @@ export const FIELD_HELP: Record<string, string> = {
17431743
"hooks.allowedSessionKeyPrefixes":
17441744
"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.",
17451745
"hooks.allowedAgentIds":
1746-
"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.",
1746+
"Allowlist of effective agent IDs that hook requests and mappings are allowed to target, including default-agent routing when agentId is omitted. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.",
17471747
"hooks.maxBodyBytes":
17481748
"Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.",
17491749
"hooks.presets":

src/config/types.hooks.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ export type HooksConfig = {
110110
*/
111111
allowedSessionKeyPrefixes?: string[];
112112
/**
113-
* Restrict explicit hook `agentId` routing to these agent ids.
114-
* Omit or include `*` to allow any agent. Set `[]` to deny all explicit `agentId` routing.
113+
* Restrict hook execution to these effective agent ids, including
114+
* default-agent routing when `agentId` is omitted. Omit or include `*` to
115+
* allow any agent. Set `[]` to deny all agent routing.
115116
*/
116117
allowedAgentIds?: string[];
117118
maxBodyBytes?: number;

src/gateway/hooks.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
extractHookToken,
88
isHookAgentAllowed,
99
normalizeHookDispatchSessionKey,
10+
resolveEffectiveHookTargetAgentId,
1011
resolveHookSessionKey,
1112
resolveHookTargetAgentId,
1213
normalizeAgentPayload,
@@ -181,7 +182,7 @@ describe("gateway hooks helpers", () => {
181182
}
182183
});
183184

184-
test("resolveHookTargetAgentId falls back to default for unknown agent ids", () => {
185+
test("resolveHookTargetAgentId preserves omitted default target intent", () => {
185186
const cfg = {
186187
hooks: { enabled: true, token: "secret" },
187188
agents: {
@@ -192,25 +193,41 @@ describe("gateway hooks helpers", () => {
192193
expect(resolveHookTargetAgentId(resolved, "hooks")).toBe("hooks");
193194
expect(resolveHookTargetAgentId(resolved, "missing-agent")).toBe("main");
194195
expect(resolveHookTargetAgentId(resolved, undefined)).toBeUndefined();
196+
expect(resolveHookTargetAgentId(resolved, " ")).toBeUndefined();
197+
expect(resolveEffectiveHookTargetAgentId(resolved, undefined)).toBe("main");
198+
expect(resolveEffectiveHookTargetAgentId(resolved, " ")).toBe("main");
195199
});
196200

197-
test("isHookAgentAllowed honors hooks.allowedAgentIds for explicit routing", () => {
201+
test("isHookAgentAllowed honors hooks.allowedAgentIds for effective target routing", () => {
198202
const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["hooks"]));
199-
expect(isHookAgentAllowed(resolved, undefined)).toBe(true);
203+
expect(isHookAgentAllowed(resolved, undefined)).toBe(false);
204+
expect(isHookAgentAllowed(resolved, "")).toBe(false);
205+
expect(isHookAgentAllowed(resolved, " ")).toBe(false);
200206
expect(isHookAgentAllowed(resolved, "hooks")).toBe(true);
201207
expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(false);
202208
});
203209

204-
test("isHookAgentAllowed treats empty allowlist as deny-all for explicit agentId", () => {
210+
test("isHookAgentAllowed treats empty allowlist as deny-all routing", () => {
205211
const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig([]));
206-
expect(isHookAgentAllowed(resolved, undefined)).toBe(true);
212+
expect(isHookAgentAllowed(resolved, undefined)).toBe(false);
213+
expect(isHookAgentAllowed(resolved, "")).toBe(false);
207214
expect(isHookAgentAllowed(resolved, "hooks")).toBe(false);
208215
expect(isHookAgentAllowed(resolved, "main")).toBe(false);
209216
});
210217

218+
test("isHookAgentAllowed allows omitted agentId when default agent is allowlisted", () => {
219+
const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["main"]));
220+
expect(isHookAgentAllowed(resolved, undefined)).toBe(true);
221+
expect(isHookAgentAllowed(resolved, "")).toBe(true);
222+
expect(isHookAgentAllowed(resolved, "hooks")).toBe(false);
223+
expect(isHookAgentAllowed(resolved, "main")).toBe(true);
224+
expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(true);
225+
});
226+
211227
test("isHookAgentAllowed treats wildcard allowlist as allow-all", () => {
212228
const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["*"]));
213229
expect(isHookAgentAllowed(resolved, undefined)).toBe(true);
230+
expect(isHookAgentAllowed(resolved, "")).toBe(true);
214231
expect(isHookAgentAllowed(resolved, "hooks")).toBe(true);
215232
expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(true);
216233
});

src/gateway/hooks.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,21 +294,24 @@ export function resolveHookTargetAgentId(
294294
return hooksConfig.agentPolicy.defaultAgentId;
295295
}
296296

297+
export function resolveEffectiveHookTargetAgentId(
298+
hooksConfig: HooksConfigResolved,
299+
agentId: string | undefined,
300+
): string {
301+
return resolveHookTargetAgentId(hooksConfig, agentId) ?? hooksConfig.agentPolicy.defaultAgentId;
302+
}
303+
297304
export function isHookAgentAllowed(
298305
hooksConfig: HooksConfigResolved,
299306
agentId: string | undefined,
300307
): boolean {
301-
// Keep backwards compatibility for callers that omit agentId.
302-
const raw = normalizeOptionalString(agentId);
303-
if (!raw) {
304-
return true;
305-
}
306308
const allowed = hooksConfig.agentPolicy.allowedAgentIds;
307309
if (allowed === undefined) {
308310
return true;
309311
}
310-
const resolved = resolveHookTargetAgentId(hooksConfig, raw);
311-
return resolved ? allowed.has(resolved) : false;
312+
// Omitted agentId still dispatches to the default agent downstream, so the
313+
// allowlist must authorize that effective target before dispatch.
314+
return allowed.has(resolveEffectiveHookTargetAgentId(hooksConfig, agentId));
312315
}
313316

314317
export const getHookAgentPolicyError = () => "agentId is not allowed by hooks.allowedAgentIds";

src/gateway/server.hooks.test.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -790,12 +790,17 @@ describe("gateway server hooks", () => {
790790
});
791791
});
792792

793-
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
793+
test("enforces hooks.allowedAgentIds for effective agent routing", async () => {
794794
testState.hooksConfig = {
795795
enabled: true,
796796
token: HOOK_TOKEN,
797797
allowedAgentIds: ["hooks"],
798798
mappings: [
799+
{
800+
match: { path: "mapped-default" },
801+
action: "agent",
802+
messageTemplate: "Mapped default: {{payload.subject}}",
803+
},
799804
{
800805
match: { path: "mapped" },
801806
action: "agent",
@@ -806,13 +811,21 @@ describe("gateway server hooks", () => {
806811
};
807812
setMainAndHooksAgents();
808813
await withGatewayServer(async ({ port }) => {
809-
mockIsolatedRunOkOnce();
810814
const resNoAgent = await postHook(port, "/hooks/agent", { message: "No explicit agent" });
811-
expect(resNoAgent.status).toBe(200);
812-
await waitForSystemEvent();
813-
const noAgentCall = cronRunCall();
814-
expect(noAgentCall?.job?.agentId).toBeUndefined();
815-
drainSystemEvents(resolveMainKey());
815+
expect(resNoAgent.status).toBe(400);
816+
const noAgentBody = (await resNoAgent.json()) as { error?: string };
817+
expect(noAgentBody.error).toContain("hooks.allowedAgentIds");
818+
expect(cronIsolatedRun).not.toHaveBeenCalled();
819+
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
820+
821+
const resEmptyAgent = await postHook(port, "/hooks/agent", {
822+
message: "Empty agent",
823+
agentId: " ",
824+
});
825+
expect(resEmptyAgent.status).toBe(400);
826+
const emptyAgentBody = (await resEmptyAgent.json()) as { error?: string };
827+
expect(emptyAgentBody.error).toContain("hooks.allowedAgentIds");
828+
expect(cronIsolatedRun).not.toHaveBeenCalled();
816829

817830
mockIsolatedRunOkOnce();
818831
const resAllowed = await postHook(port, "/hooks/agent", {
@@ -833,6 +846,13 @@ describe("gateway server hooks", () => {
833846
const deniedBody = (await resDenied.json()) as { error?: string };
834847
expect(deniedBody.error).toContain("hooks.allowedAgentIds");
835848

849+
const resMappedDefaultDenied = await postHook(port, "/hooks/mapped-default", {
850+
subject: "hello",
851+
});
852+
expect(resMappedDefaultDenied.status).toBe(400);
853+
const mappedDefaultDeniedBody = (await resMappedDefaultDenied.json()) as { error?: string };
854+
expect(mappedDefaultDeniedBody.error).toContain("hooks.allowedAgentIds");
855+
836856
const resMappedDenied = await postHook(port, "/hooks/mapped", { subject: "hello" });
837857
expect(resMappedDenied.status).toBe(400);
838858
const mappedDeniedBody = (await resMappedDenied.json()) as { error?: string };
@@ -841,6 +861,43 @@ describe("gateway server hooks", () => {
841861
});
842862
});
843863

864+
test("allows omitted agentId when the default target is allowlisted", async () => {
865+
testState.hooksConfig = {
866+
enabled: true,
867+
token: HOOK_TOKEN,
868+
allowRequestSessionKey: true,
869+
allowedSessionKeyPrefixes: ["hook:", "agent:"],
870+
allowedAgentIds: ["main"],
871+
};
872+
testState.sessionConfig = { scope: "global" };
873+
setMainAndHooksAgents();
874+
await withGatewayServer(async ({ port }) => {
875+
mockIsolatedRunOkOnce();
876+
const resNoAgent = await postHook(port, "/hooks/agent", {
877+
message: "Default target",
878+
sessionKey: "agent:hooks:slack:channel:c123",
879+
});
880+
expect(resNoAgent.status).toBe(200);
881+
await waitForSystemEventTexts(resolveMainKey());
882+
const noAgentCall = cronRunCall();
883+
expect(noAgentCall?.job?.agentId).toBeUndefined();
884+
expect(noAgentCall?.sessionKey).toBe("agent:main:slack:channel:c123");
885+
expect(peekSystemEventEntries("agent:main:main")).toStrictEqual([]);
886+
drainSystemEvents(resolveMainKey());
887+
888+
mockIsolatedRunOkOnce();
889+
const resBlankAgent = await postHook(port, "/hooks/agent", {
890+
message: "Blank target",
891+
agentId: " ",
892+
});
893+
expect(resBlankAgent.status).toBe(200);
894+
await waitForSystemEventTexts(resolveMainKey());
895+
const blankAgentCall = cronRunCall();
896+
expect(blankAgentCall?.job?.agentId).toBeUndefined();
897+
drainSystemEvents(resolveMainKey());
898+
});
899+
});
900+
844901
test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => {
845902
testState.hooksConfig = {
846903
enabled: true,
@@ -851,6 +908,13 @@ describe("gateway server hooks", () => {
851908
list: [{ id: "main", default: true }, { id: "hooks" }],
852909
};
853910
await withGatewayServer(async ({ port }) => {
911+
const resNoAgent = await postHook(port, "/hooks/agent", {
912+
message: "Denied by omission",
913+
});
914+
expect(resNoAgent.status).toBe(400);
915+
const noAgentBody = (await resNoAgent.json()) as { error?: string };
916+
expect(noAgentBody.error).toContain("hooks.allowedAgentIds");
917+
854918
const resDenied = await postHook(port, "/hooks/agent", {
855919
message: "Denied",
856920
agentId: "hooks",

src/gateway/server/hooks-request-handler.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
normalizeHookHeaders,
2424
normalizeWakePayload,
2525
readJsonBody,
26+
resolveEffectiveHookTargetAgentId,
2627
resolveHookChannel,
2728
resolveHookDeliver,
2829
resolveHookIdempotencyKey,
@@ -281,12 +282,16 @@ export function createHooksRequestHandler(
281282
return true;
282283
}
283284
const targetAgentId = resolveHookTargetAgentId(hooksConfig, normalized.value.agentId);
285+
const effectiveTargetAgentId = resolveEffectiveHookTargetAgentId(
286+
hooksConfig,
287+
normalized.value.agentId,
288+
);
284289
const replayKey = buildHookReplayCacheKey({
285290
pathKey: "agent",
286291
token,
287292
idempotencyKey,
288293
dispatchScope: {
289-
agentId: targetAgentId ?? null,
294+
agentId: effectiveTargetAgentId,
290295
sessionKey:
291296
normalized.value.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null,
292297
message: normalized.value.message,
@@ -307,7 +312,7 @@ export function createHooksRequestHandler(
307312
}
308313
const normalizedDispatchSessionKey = normalizeHookDispatchSessionKey({
309314
sessionKey: sessionKey.value,
310-
targetAgentId,
315+
targetAgentId: effectiveTargetAgentId,
311316
});
312317
const allowedPrefixes = hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
313318
if (
@@ -376,9 +381,13 @@ export function createHooksRequestHandler(
376381
return true;
377382
}
378383
const targetAgentId = resolveHookTargetAgentId(hooksConfig, mapped.action.agentId);
384+
const effectiveTargetAgentId = resolveEffectiveHookTargetAgentId(
385+
hooksConfig,
386+
mapped.action.agentId,
387+
);
379388
const normalizedDispatchSessionKey = normalizeHookDispatchSessionKey({
380389
sessionKey: sessionKey.value,
381-
targetAgentId,
390+
targetAgentId: effectiveTargetAgentId,
382391
});
383392
const allowedPrefixes = hooksConfig.sessionPolicy.allowedSessionKeyPrefixes;
384393
if (
@@ -393,7 +402,7 @@ export function createHooksRequestHandler(
393402
token,
394403
idempotencyKey,
395404
dispatchScope: {
396-
agentId: targetAgentId ?? null,
405+
agentId: effectiveTargetAgentId,
397406
sessionKey:
398407
mapped.action.sessionKey ?? hooksConfig.sessionPolicy.defaultSessionKey ?? null,
399408
message: mapped.action.message,

0 commit comments

Comments
 (0)