diff --git a/CHANGELOG.md b/CHANGELOG.md index a39bdd4ce42..0462cdd678e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. - Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks. +- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys. - Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 70da3e24d1e..699e6659ca3 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -189,6 +189,12 @@ out to QMD for retrieval. Key points: - `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session). Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD hits in groups/channels. + - `match.keyPrefix` matches the **normalized** session key (lowercased, with any + leading `agent::` stripped). Example: `discord:channel:`. + - `match.rawKeyPrefix` matches the **raw** session key (lowercased), including + `agent::`. Example: `agent:main:discord:`. + - Legacy: `match.keyPrefix: "agent:..."` is still treated as a raw-key prefix, + but prefer `rawKeyPrefix` for clarity. - When `scope` denies a search, OpenClaw logs a warning with the derived `channel`/`chatType` so empty results are easier to debug. - Snippets sourced outside the workspace show up as @@ -216,7 +222,13 @@ memory: { limits: { maxResults: 6, timeoutMs: 4000 }, scope: { default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }] + rules: [ + { action: "allow", match: { chatType: "direct" } }, + // Normalized session-key prefix (strips `agent::`). + { action: "deny", match: { keyPrefix: "discord:channel:" } }, + // Raw session-key prefix (includes `agent::`). + { action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } }, + ] }, paths: [ { name: "docs", path: "~/notes", pattern: "**/*.md" } diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 54dfb21327f..edd6f415d28 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -123,6 +123,8 @@ Block delivery for specific session types without listing individual ids. rules: [ { action: "deny", match: { channel: "discord", chatType: "group" } }, { action: "deny", match: { keyPrefix: "cron:" } }, + // Match the raw session key (including the `agent::` prefix). + { action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } }, ], default: "allow", }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 12f2ab8a002..9686f96ee40 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1174,7 +1174,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. -- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), or `keyPrefix`. First deny wins. +- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. - **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation. diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7f399156785..145c805e79a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -233,7 +233,7 @@ export const FIELD_HELP: Record = { "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", "memory.qmd.scope": - "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", + "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only). Use match.rawKeyPrefix to match full agent-prefixed session keys.", "agents.defaults.memorySearch.cache.maxEntries": "Optional cap on cached embeddings (best-effort).", "agents.defaults.memorySearch.sync.onSearch": diff --git a/src/config/types.base.ts b/src/config/types.base.ts index f42cbd54a66..0836448b6f2 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -51,7 +51,13 @@ export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { channel?: string; chatType?: ChatType; + /** + * Session key prefix match. + * Note: some consumers match against a normalized key (for example, stripping `agent::`). + */ keyPrefix?: string; + /** Optional raw session-key prefix match for consumers that normalize session keys. */ + rawKeyPrefix?: string; }; export type SessionSendPolicyRule = { action: SessionSendPolicyAction; diff --git a/src/config/zod-schema.allowdeny.ts b/src/config/zod-schema.allowdeny.ts index c2d9c5d6839..a5dc4cc9651 100644 --- a/src/config/zod-schema.allowdeny.ts +++ b/src/config/zod-schema.allowdeny.ts @@ -26,6 +26,7 @@ export function createAllowDenyChannelRulesSchema() { channel: z.string().optional(), chatType: AllowDenyChatTypeSchema, keyPrefix: z.string().optional(), + rawKeyPrefix: z.string().optional(), }) .strict() .optional(), diff --git a/src/memory/qmd-scope.test.ts b/src/memory/qmd-scope.test.ts index 8aa1f176f58..5a826e9c9b3 100644 --- a/src/memory/qmd-scope.test.ts +++ b/src/memory/qmd-scope.test.ts @@ -33,4 +33,22 @@ describe("qmd scope", () => { expect(isQmdScopeAllowed(scope, "agent:agent-1:workspace:group:123")).toBe(true); expect(isQmdScopeAllowed(scope, "agent:agent-1:other:group:123")).toBe(false); }); + + it("supports rawKeyPrefix matches for agent-prefixed keys", () => { + const scope: ResolvedQmdConfig["scope"] = { + default: "allow", + rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } }], + }; + expect(isQmdScopeAllowed(scope, "agent:main:discord:channel:c123")).toBe(false); + expect(isQmdScopeAllowed(scope, "agent:main:slack:channel:c123")).toBe(true); + }); + + it("keeps legacy agent-prefixed keyPrefix rules working", () => { + const scope: ResolvedQmdConfig["scope"] = { + default: "allow", + rules: [{ action: "deny", match: { keyPrefix: "agent:main:discord:" } }], + }; + expect(isQmdScopeAllowed(scope, "agent:main:discord:channel:c123")).toBe(false); + expect(isQmdScopeAllowed(scope, "agent:main:slack:channel:c123")).toBe(true); + }); }); diff --git a/src/memory/qmd-scope.ts b/src/memory/qmd-scope.ts index a31ad1fb54b..050a7fa7e8d 100644 --- a/src/memory/qmd-scope.ts +++ b/src/memory/qmd-scope.ts @@ -15,6 +15,7 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey? const channel = parsed.channel; const chatType = parsed.chatType; const normalizedKey = parsed.normalizedKey ?? ""; + const rawKey = sessionKey?.trim().toLowerCase() ?? ""; for (const rule of scope.rules ?? []) { if (!rule) { continue; @@ -26,9 +27,23 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey? if (match.chatType && match.chatType !== chatType) { continue; } - if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) { + const normalizedPrefix = match.keyPrefix?.trim().toLowerCase() || undefined; + const rawPrefix = match.rawKeyPrefix?.trim().toLowerCase() || undefined; + + if (rawPrefix && !rawKey.startsWith(rawPrefix)) { continue; } + if (normalizedPrefix) { + // Backward compat: older configs used `keyPrefix: "agent::..."` to match raw keys. + const isLegacyRaw = normalizedPrefix.startsWith("agent:"); + if (isLegacyRaw) { + if (!rawKey.startsWith(normalizedPrefix)) { + continue; + } + } else if (!normalizedKey.startsWith(normalizedPrefix)) { + continue; + } + } return rule.action === "allow"; } const fallback = scope.default ?? "allow"; diff --git a/src/sessions/send-policy.test.ts b/src/sessions/send-policy.test.ts index ed01e2d5328..128add70d28 100644 --- a/src/sessions/send-policy.test.ts +++ b/src/sessions/send-policy.test.ts @@ -55,4 +55,17 @@ describe("resolveSendPolicy", () => { } as OpenClawConfig; expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny"); }); + + it("rule match by rawKeyPrefix", () => { + const cfg = { + session: { + sendPolicy: { + default: "allow", + rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } }], + }, + }, + } as OpenClawConfig; + expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:discord:group:dev" })).toBe("deny"); + expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:slack:group:dev" })).toBe("allow"); + }); }); diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 54cc1ef7711..76c206d419c 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -85,6 +85,8 @@ export function resolveSendPolicy(params: { normalizeChatType(deriveChatTypeFromKey(params.sessionKey)); const rawSessionKey = params.sessionKey ?? ""; const strippedSessionKey = stripAgentSessionKeyPrefix(rawSessionKey) ?? ""; + const rawSessionKeyNorm = rawSessionKey.toLowerCase(); + const strippedSessionKeyNorm = strippedSessionKey.toLowerCase(); let allowedMatch = false; for (const rule of policy.rules ?? []) { @@ -96,6 +98,7 @@ export function resolveSendPolicy(params: { const matchChannel = normalizeMatchValue(match.channel); const matchChatType = normalizeChatType(match.chatType); const matchPrefix = normalizeMatchValue(match.keyPrefix); + const matchRawPrefix = normalizeMatchValue(match.rawKeyPrefix); if (matchChannel && matchChannel !== channel) { continue; @@ -103,10 +106,13 @@ export function resolveSendPolicy(params: { if (matchChatType && matchChatType !== chatType) { continue; } + if (matchRawPrefix && !rawSessionKeyNorm.startsWith(matchRawPrefix)) { + continue; + } if ( matchPrefix && - !rawSessionKey.startsWith(matchPrefix) && - !strippedSessionKey.startsWith(matchPrefix) + !rawSessionKeyNorm.startsWith(matchPrefix) && + !strippedSessionKeyNorm.startsWith(matchPrefix) ) { continue; }