mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 07:01:40 +03: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.
|
- 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: 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: 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: 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: 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.
|
- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
|
||||||
|
|||||||
+13
-1
@@ -189,6 +189,12 @@ out to QMD for retrieval. Key points:
|
|||||||
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
|
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
|
||||||
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
|
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
|
||||||
hits in groups/channels.
|
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
|
- When `scope` denies a search, OpenClaw logs a warning with the derived
|
||||||
`channel`/`chatType` so empty results are easier to debug.
|
`channel`/`chatType` so empty results are easier to debug.
|
||||||
- Snippets sourced outside the workspace show up as
|
- Snippets sourced outside the workspace show up as
|
||||||
@@ -216,7 +222,13 @@ memory: {
|
|||||||
limits: { maxResults: 6, timeoutMs: 4000 },
|
limits: { maxResults: 6, timeoutMs: 4000 },
|
||||||
scope: {
|
scope: {
|
||||||
default: "deny",
|
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: [
|
paths: [
|
||||||
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
|
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ Block delivery for specific session types without listing individual ids.
|
|||||||
rules: [
|
rules: [
|
||||||
{ action: "deny", match: { channel: "discord", chatType: "group" } },
|
{ action: "deny", match: { channel: "discord", chatType: "group" } },
|
||||||
{ action: "deny", match: { keyPrefix: "cron:" } },
|
{ action: "deny", match: { keyPrefix: "cron:" } },
|
||||||
|
// Match the raw session key (including the `agent:<id>:` prefix).
|
||||||
|
{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
|
||||||
],
|
],
|
||||||
default: "allow",
|
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.
|
- **`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`.
|
- **`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.
|
- **`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.
|
- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation.
|
||||||
|
|
||||||
</Accordion>
|
</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.maxInjectedChars": "Max total characters injected from QMD hits per turn.",
|
||||||
"memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).",
|
"memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).",
|
||||||
"memory.qmd.scope":
|
"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":
|
"agents.defaults.memorySearch.cache.maxEntries":
|
||||||
"Optional cap on cached embeddings (best-effort).",
|
"Optional cap on cached embeddings (best-effort).",
|
||||||
"agents.defaults.memorySearch.sync.onSearch":
|
"agents.defaults.memorySearch.sync.onSearch":
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ export type SessionSendPolicyAction = "allow" | "deny";
|
|||||||
export type SessionSendPolicyMatch = {
|
export type SessionSendPolicyMatch = {
|
||||||
channel?: string;
|
channel?: string;
|
||||||
chatType?: ChatType;
|
chatType?: ChatType;
|
||||||
|
/**
|
||||||
|
* Session key prefix match.
|
||||||
|
* Note: some consumers match against a normalized key (for example, stripping `agent:<id>:`).
|
||||||
|
*/
|
||||||
keyPrefix?: string;
|
keyPrefix?: string;
|
||||||
|
/** Optional raw session-key prefix match for consumers that normalize session keys. */
|
||||||
|
rawKeyPrefix?: string;
|
||||||
};
|
};
|
||||||
export type SessionSendPolicyRule = {
|
export type SessionSendPolicyRule = {
|
||||||
action: SessionSendPolicyAction;
|
action: SessionSendPolicyAction;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function createAllowDenyChannelRulesSchema() {
|
|||||||
channel: z.string().optional(),
|
channel: z.string().optional(),
|
||||||
chatType: AllowDenyChatTypeSchema,
|
chatType: AllowDenyChatTypeSchema,
|
||||||
keyPrefix: z.string().optional(),
|
keyPrefix: z.string().optional(),
|
||||||
|
rawKeyPrefix: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.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:workspace:group:123")).toBe(true);
|
||||||
expect(isQmdScopeAllowed(scope, "agent:agent-1:other:group:123")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-1
@@ -15,6 +15,7 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?
|
|||||||
const channel = parsed.channel;
|
const channel = parsed.channel;
|
||||||
const chatType = parsed.chatType;
|
const chatType = parsed.chatType;
|
||||||
const normalizedKey = parsed.normalizedKey ?? "";
|
const normalizedKey = parsed.normalizedKey ?? "";
|
||||||
|
const rawKey = sessionKey?.trim().toLowerCase() ?? "";
|
||||||
for (const rule of scope.rules ?? []) {
|
for (const rule of scope.rules ?? []) {
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
continue;
|
continue;
|
||||||
@@ -26,9 +27,23 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?
|
|||||||
if (match.chatType && match.chatType !== chatType) {
|
if (match.chatType && match.chatType !== chatType) {
|
||||||
continue;
|
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;
|
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";
|
return rule.action === "allow";
|
||||||
}
|
}
|
||||||
const fallback = scope.default ?? "allow";
|
const fallback = scope.default ?? "allow";
|
||||||
|
|||||||
@@ -55,4 +55,17 @@ describe("resolveSendPolicy", () => {
|
|||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny");
|
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));
|
normalizeChatType(deriveChatTypeFromKey(params.sessionKey));
|
||||||
const rawSessionKey = params.sessionKey ?? "";
|
const rawSessionKey = params.sessionKey ?? "";
|
||||||
const strippedSessionKey = stripAgentSessionKeyPrefix(rawSessionKey) ?? "";
|
const strippedSessionKey = stripAgentSessionKeyPrefix(rawSessionKey) ?? "";
|
||||||
|
const rawSessionKeyNorm = rawSessionKey.toLowerCase();
|
||||||
|
const strippedSessionKeyNorm = strippedSessionKey.toLowerCase();
|
||||||
|
|
||||||
let allowedMatch = false;
|
let allowedMatch = false;
|
||||||
for (const rule of policy.rules ?? []) {
|
for (const rule of policy.rules ?? []) {
|
||||||
@@ -96,6 +98,7 @@ export function resolveSendPolicy(params: {
|
|||||||
const matchChannel = normalizeMatchValue(match.channel);
|
const matchChannel = normalizeMatchValue(match.channel);
|
||||||
const matchChatType = normalizeChatType(match.chatType);
|
const matchChatType = normalizeChatType(match.chatType);
|
||||||
const matchPrefix = normalizeMatchValue(match.keyPrefix);
|
const matchPrefix = normalizeMatchValue(match.keyPrefix);
|
||||||
|
const matchRawPrefix = normalizeMatchValue(match.rawKeyPrefix);
|
||||||
|
|
||||||
if (matchChannel && matchChannel !== channel) {
|
if (matchChannel && matchChannel !== channel) {
|
||||||
continue;
|
continue;
|
||||||
@@ -103,10 +106,13 @@ export function resolveSendPolicy(params: {
|
|||||||
if (matchChatType && matchChatType !== chatType) {
|
if (matchChatType && matchChatType !== chatType) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (matchRawPrefix && !rawSessionKeyNorm.startsWith(matchRawPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
matchPrefix &&
|
matchPrefix &&
|
||||||
!rawSessionKey.startsWith(matchPrefix) &&
|
!rawSessionKeyNorm.startsWith(matchPrefix) &&
|
||||||
!strippedSessionKey.startsWith(matchPrefix)
|
!strippedSessionKeyNorm.startsWith(matchPrefix)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user