fix(memory): prevent QMD scope deny bypass

This commit is contained in:
Peter Steinberger
2026-02-15 02:41:30 +00:00
parent 014b42dd45
commit f9bb748a6c
11 changed files with 80 additions and 6 deletions

View File

@@ -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.

View File

@@ -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:<id>:` stripped). Example: `discord:channel:`.
- `match.rawKeyPrefix` matches the **raw** session key (lowercased), including
`agent:<id>:`. 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:<id>:`).
{ action: "deny", match: { keyPrefix: "discord:channel:" } },
// Raw session-key prefix (includes `agent:<id>:`).
{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
]
},
paths: [
{ name: "docs", path: "~/notes", pattern: "**/*.md" }

View File

@@ -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:<id>:` prefix).
{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
],
default: "allow",
},

View File

@@ -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.
</Accordion>

View File

@@ -233,7 +233,7 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -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:<id>:`).
*/
keyPrefix?: string;
/** Optional raw session-key prefix match for consumers that normalize session keys. */
rawKeyPrefix?: string;
};
export type SessionSendPolicyRule = {
action: SessionSendPolicyAction;

View File

@@ -26,6 +26,7 @@ export function createAllowDenyChannelRulesSchema() {
channel: z.string().optional(),
chatType: AllowDenyChatTypeSchema,
keyPrefix: z.string().optional(),
rawKeyPrefix: z.string().optional(),
})
.strict()
.optional(),

View File

@@ -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);
});
});

View File

@@ -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:<id>:..."` 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";

View File

@@ -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");
});
});

View File

@@ -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;
}