mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(memory): prevent QMD scope deny bypass
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ export function createAllowDenyChannelRulesSchema() {
|
||||
channel: z.string().optional(),
|
||||
chatType: AllowDenyChatTypeSchema,
|
||||
keyPrefix: z.string().optional(),
|
||||
rawKeyPrefix: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user