feat: add configurable context visibility

This commit is contained in:
Peter Steinberger
2026-04-03 04:33:52 +09:00
parent d4d2d9e479
commit 35e1605147
31 changed files with 406 additions and 2 deletions

View File

@@ -56,6 +56,7 @@ These are frequently reported but are typically closed with no code change:
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
- Reports that only show quoted/replied/thread/forwarded supplemental context from non-allowlisted senders being visible to the model, without demonstrating an auth, policy, approval, or sandbox boundary bypass.
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics.
- Reports that treat `POST /tools/invoke` under shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) as a narrower per-request/per-scope authorization surface. That endpoint is designed as the same trusted-operator HTTP boundary: shared-secret bearer auth is full operator access there, narrower `x-openclaw-scopes` values do not reduce that path, and owner-only tool policy follows the shared-secret operator contract.
@@ -167,6 +168,24 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime.
- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk.
## Context Visibility and Allowlists
OpenClaw distinguishes:
- **Trigger authorization**: who can trigger the agent (`dmPolicy`, `groupPolicy`, allowlists, mention gates)
- **Context visibility**: what supplemental context is provided to the model (reply body, quoted text, thread history, forwarded metadata)
In current releases, allowlists primarily gate triggering and owner-style command access. They do not guarantee universal supplemental-context redaction across every channel/surface.
Current channel behavior is not fully uniform:
- some channels already filter parts of supplemental context by sender allowlist
- other channels still pass supplemental context as received
Reports that only show supplemental-context visibility differences are typically hardening/consistency findings unless they also demonstrate a documented boundary bypass (auth, policy, approvals, sandbox, or equivalent).
Hardening roadmap may add explicit visibility modes (for example `all`, `allowlist`, `allowlist_quote`) so operators can opt into stricter context filtering with predictable tradeoffs.
## Agent and Model Assumptions
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.

View File

@@ -643,6 +643,8 @@ Default slash command settings:
- thread config inherits parent channel config unless a thread-specific entry exists
Channel topics are injected as **untrusted** context (not as system prompt).
Reply and quoted-message context currently stays as received.
Discord allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
</Accordion>

View File

@@ -36,6 +36,28 @@ requireMention? yes -> mentioned? no -> store for context only
otherwise -> reply
```
## Context visibility and allowlists
Two different controls are involved in group safety:
- **Trigger authorization**: who can trigger the agent (`groupPolicy`, `groups`, `groupAllowFrom`, channel-specific allowlists).
- **Context visibility**: what supplemental context is injected into the model (reply text, quotes, thread history, forwarded metadata).
By default, OpenClaw prioritizes normal chat behavior and keeps context mostly as received. This means allowlists primarily decide who can trigger actions, not a universal redaction boundary for every quoted or historical snippet.
Current behavior is channel-specific:
- Some channels already apply sender-based filtering for supplemental context in specific paths (for example Slack thread seeding, Matrix reply/thread lookups).
- Other channels still pass quote/reply/forward context through as received.
Hardening direction (planned):
- `contextVisibility: "all"` (default) keeps current as-received behavior.
- `contextVisibility: "allowlist"` filters supplemental context to allowlisted senders.
- `contextVisibility: "allowlist_quote"` is `allowlist` plus one explicit quote/reply exception.
Until this hardening model is implemented consistently across channels, expect differences by surface.
![Group message flow](/images/groups-flow.svg)
If you want...

View File

@@ -590,6 +590,7 @@ Current behavior:
- The current trigger message is not included in `InboundHistory`; it stays in the main inbound body for that turn.
- Retries of the same Matrix event reuse the original history snapshot instead of drifting forward to newer room messages.
- Fetched room context (including reply and thread context lookups) is filtered by sender allowlists (`groupAllowFrom`), so non-allowlisted messages are excluded from agent context.
- This filtering is channel-level hardening behavior. Other channels may still expose supplemental context as received.
## DM and room policy example

View File

@@ -302,6 +302,8 @@ The action is gated by `channels.msteams.actions.memberInfo` (default: enabled w
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- Fetched thread history is filtered by sender allowlists (`allowFrom` / `groupAllowFrom`), so thread context seeding only includes messages from allowed senders.
- Quoted attachment context (`ReplyTo*` derived from Teams reply HTML) is currently passed as received.
- In other words, allowlists gate who can trigger the agent; only specific supplemental context paths are filtered today.
- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms["<user_id>"].historyLimit`.
## Current Teams RSC Permissions (Manifest)

View File

@@ -354,6 +354,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- Assistant thread status updates (for "is typing..." indicators in threads) use `assistant.threads.setStatus` and require bot scope `assistant:write`.
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs

View File

@@ -759,6 +759,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size.
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies).
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
- reply/quote/forward supplemental context is currently passed as received.
- Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
- DM history controls:
- `channels.telegram.dmHistoryLimit`
- `channels.telegram.dms["<user_id>"].historyLimit`

View File

@@ -169,6 +169,29 @@ If more than one person can DM your bot:
- Never combine shared DMs with broad tool access.
- This hardens cooperative/shared inboxes, but is not designed as hostile co-tenant isolation when users share host/config write access.
## Context visibility model
OpenClaw separates two concepts:
- **Trigger authorization**: who can trigger the agent (`dmPolicy`, `groupPolicy`, allowlists, mention gates).
- **Context visibility**: what supplemental context is injected into model input (reply body, quoted text, thread history, forwarded metadata).
In the current product, allowlists primarily gate triggers and command authorization. They are not a guaranteed universal redaction boundary for every supplemental context field on every channel.
Current behavior is channel-specific:
- Some channels already filter parts of supplemental context by sender allowlists.
- Other channels still pass supplemental context through as received.
Advisory triage guidance:
- Claims that only show "model can see quoted or historical text from non-allowlisted senders" are usually hardening and consistency findings, not auth or sandbox boundary bypasses by themselves.
- To be security-impacting, reports still need a demonstrated trust-boundary bypass (auth, policy, sandbox, approval, or another documented boundary).
Hardening direction:
- OpenClaw maintainers may introduce explicit context visibility modes such as `all`, `allowlist`, and `allowlist_quote` to make this behavior intentional and configurable across channels.
## What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
resolveChannelContextVisibilityMode,
resolveDefaultContextVisibility,
} from "./context-visibility.js";
describe("resolveDefaultContextVisibility", () => {
it("reads channels.defaults.contextVisibility", () => {
expect(
resolveDefaultContextVisibility({
channels: {
defaults: {
contextVisibility: "allowlist_quote",
},
},
}),
).toBe("allowlist_quote");
});
});
describe("resolveChannelContextVisibilityMode", () => {
it("prefers explicitly provided mode", () => {
expect(
resolveChannelContextVisibilityMode({
cfg: {},
channel: "slack",
configuredContextVisibility: "allowlist",
}),
).toBe("allowlist");
});
it("falls back to account mode then channel mode then defaults", () => {
const cfg = {
channels: {
defaults: {
contextVisibility: "allowlist_quote",
},
slack: {
contextVisibility: "allowlist",
accounts: {
work: {
contextVisibility: "all",
},
},
},
},
};
expect(
resolveChannelContextVisibilityMode({
cfg,
channel: "slack",
accountId: "work",
}),
).toBe("all");
expect(
resolveChannelContextVisibilityMode({
cfg,
channel: "slack",
accountId: "missing",
}),
).toBe("allowlist");
expect(
resolveChannelContextVisibilityMode({
cfg: { channels: { defaults: { contextVisibility: "allowlist_quote" } } },
channel: "signal",
}),
).toBe("allowlist_quote");
});
it("defaults to all when unset", () => {
expect(
resolveChannelContextVisibilityMode({
cfg: {},
channel: "telegram",
}),
).toBe("all");
});
});

View File

@@ -0,0 +1,45 @@
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { normalizeAccountId } from "../routing/session-key.js";
import type { OpenClawConfig } from "./config.js";
import type { ContextVisibilityMode } from "./types.base.js";
type ChannelContextVisibilityConfig = {
contextVisibility?: ContextVisibilityMode;
accounts?: Record<string, { contextVisibility?: ContextVisibilityMode }>;
};
export type ContextVisibilityDefaultsConfig = {
channels?: {
defaults?: {
contextVisibility?: ContextVisibilityMode;
};
};
};
export function resolveDefaultContextVisibility(
cfg: ContextVisibilityDefaultsConfig,
): ContextVisibilityMode | undefined {
return cfg.channels?.defaults?.contextVisibility;
}
export function resolveChannelContextVisibilityMode(params: {
cfg: OpenClawConfig;
channel: string;
accountId?: string | null;
configuredContextVisibility?: ContextVisibilityMode;
}): ContextVisibilityMode {
if (params.configuredContextVisibility) {
return params.configuredContextVisibility;
}
const channelConfig = params.cfg.channels?.[params.channel] as
| ChannelContextVisibilityConfig
| undefined;
const accountId = normalizeAccountId(params.accountId);
const accountMode = resolveAccountEntry(channelConfig?.accounts, accountId)?.contextVisibility;
return (
accountMode ??
channelConfig?.contextVisibility ??
resolveDefaultContextVisibility(params.cfg) ??
"all"
);
}

View File

@@ -256,6 +256,7 @@ const TARGET_KEYS = [
"channels",
"channels.defaults",
"channels.defaults.groupPolicy",
"channels.defaults.contextVisibility",
"channels.defaults.heartbeat",
"channels.defaults.heartbeat.showOk",
"channels.defaults.heartbeat.showAlerts",
@@ -429,6 +430,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
],
"messages.queue.drop": ['"old"', '"new"', '"summarize"'],
"channels.defaults.groupPolicy": ['"open"', '"disabled"', '"allowlist"'],
"channels.defaults.contextVisibility": ['"all"', '"allowlist"', '"allowlist_quote"'],
"gateway.mode": ['"local"', '"remote"'],
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],

View File

@@ -1443,6 +1443,8 @@ export const FIELD_HELP: Record<string, string> = {
"Default channel behavior applied across providers when provider-specific settings are not set. Use this to enforce consistent baseline policy before per-provider tuning.",
"channels.defaults.groupPolicy":
'Default group policy across channels: "open", "disabled", or "allowlist". Keep "allowlist" for safer production setups unless broad group participation is intentional.',
"channels.defaults.contextVisibility":
'Default supplemental context visibility for fetched quote/thread/history content: "all" (keep all context), "allowlist" (only allowlisted senders), or "allowlist_quote" (allowlist + keep explicit quotes).',
"channels.defaults.heartbeat":
"Default heartbeat visibility settings for status messages emitted by providers/channels. Tune this globally to reduce noisy healthy-state updates while keeping alerts visible.",
"channels.defaults.heartbeat.showOk":

View File

@@ -735,6 +735,7 @@ export const FIELD_LABELS: Record<string, string> = {
channels: "Channels",
"channels.defaults": "Channel Defaults",
"channels.defaults.groupPolicy": "Default Group Policy",
"channels.defaults.contextVisibility": "Default Context Visibility",
"channels.defaults.heartbeat": "Default Heartbeat Visibility",
"channels.defaults.heartbeat.showOk": "Heartbeat Show OK",
"channels.defaults.heartbeat.showAlerts": "Heartbeat Show Alerts",

View File

@@ -7,6 +7,7 @@ export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-ch
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
export type OutboundRetryConfig = {
/** Max retry attempts for outbound requests (default: 3). */

View File

@@ -1,5 +1,6 @@
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -31,6 +32,13 @@ export type CommonChannelMessagingConfig = {
groupAllowFrom?: Array<string | number>;
/** Group/channel message handling policy. */
groupPolicy?: GroupPolicy;
/**
* Supplemental context visibility policy for fetched/group context.
* - "all": include all quoted/thread/history context
* - "allowlist": only include context from allowlisted senders
* - "allowlist_quote": same as allowlist, but keep explicit quote/reply context
*/
contextVisibility?: ContextVisibilityMode;
/** Max group/channel messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@@ -1,4 +1,4 @@
import type { GroupPolicy } from "./types.base.js";
import type { ContextVisibilityMode, GroupPolicy } from "./types.base.js";
export type ChannelHeartbeatVisibilityConfig = {
/** Show HEARTBEAT_OK acknowledgments in chat (default: false). */
@@ -19,6 +19,7 @@ export type ChannelHealthMonitorConfig = {
export type ChannelDefaultsConfig = {
groupPolicy?: GroupPolicy;
contextVisibility?: ContextVisibilityMode;
/** Default heartbeat visibility for all channels. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
@@ -38,6 +39,7 @@ export type ExtensionChannelConfig = {
defaultAccount?: string;
dmPolicy?: string;
groupPolicy?: GroupPolicy;
contextVisibility?: ContextVisibilityMode;
healthMonitor?: ChannelHealthMonitorConfig;
accounts?: Record<string, unknown>;
[key: string]: unknown;

View File

@@ -1,6 +1,7 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -249,6 +250,8 @@ export type DiscordAccountConfig = {
* - "allowlist": only allow channels present in discord.guilds.*.channels
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */

View File

@@ -1,5 +1,6 @@
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -47,6 +48,8 @@ export type IMessageAccountConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@@ -1,5 +1,6 @@
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -83,6 +84,8 @@ export type MSTeamsConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */

View File

@@ -1,5 +1,6 @@
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -142,6 +143,8 @@ export type SlackAccountConfig = {
* - "allowlist": only allow channels present in channels.slack.channels
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Max channel messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@@ -1,6 +1,7 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -131,6 +132,8 @@ export type TelegramAccountConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@@ -1,6 +1,7 @@
import type { ReactionLevel } from "../utils/reaction-level.js";
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
@@ -61,6 +62,8 @@ type WhatsAppSharedConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Supplemental context visibility policy (all|allowlist|allowlist_quote). */
contextVisibility?: ContextVisibilityMode;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@@ -336,6 +336,7 @@ export const TypingModeSchema = z.union([
export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
export const ContextVisibilityModeSchema = z.enum(["all", "allowlist", "allowlist_quote"]);
export const BlockStreamingCoalesceSchema = z
.object({
@@ -348,6 +349,7 @@ export const BlockStreamingCoalesceSchema = z
export const ReplyRuntimeConfigSchemaShape = {
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),

View File

@@ -20,6 +20,7 @@ import {
import {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
ContextVisibilityModeSchema,
DmConfigSchema,
DmPolicySchema,
ExecutableTokenSchema,
@@ -222,6 +223,7 @@ export const TelegramAccountSchemaBase = z
defaultTo: z.union([z.string(), z.number()]).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
@@ -509,6 +511,7 @@ export const DiscordAccountSchema = z
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
@@ -910,6 +913,7 @@ export const SlackAccountSchema = z
dangerouslyAllowNameMatching: z.boolean().optional(),
requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
@@ -974,6 +978,7 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
signingSecret: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional().default("/slack/events"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
@@ -1072,6 +1077,7 @@ export const SignalAccountSchemaBase = z
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
groups: SignalGroupsSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
@@ -1194,6 +1200,7 @@ export const IrcAccountSchemaBase = z
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
mentionPatterns: z.array(z.string()).optional(),
historyLimit: z.number().int().min(0).optional(),
@@ -1304,6 +1311,7 @@ export const IMessageAccountSchemaBase = z
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
@@ -1433,6 +1441,7 @@ export const BlueBubblesAccountSchemaBase = z
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
@@ -1548,6 +1557,7 @@ export const MSTeamsConfigSchema = z
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),

View File

@@ -6,6 +6,7 @@ import {
} from "./zod-schema.channels.js";
import {
BlockStreamingCoalesceSchema,
ContextVisibilityModeSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
@@ -48,6 +49,7 @@ const WhatsAppSharedSchema = z.object({
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import type { ChannelsConfig } from "./types.channels.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import { GroupPolicySchema } from "./zod-schema.core.js";
import { ContextVisibilityModeSchema, GroupPolicySchema } from "./zod-schema.core.js";
import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
@@ -107,6 +107,7 @@ export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
defaults: z
.object({
groupPolicy: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict()

View File

@@ -7,6 +7,7 @@ export {
} from "../channels/plugins/config-schema.js";
export {
BlockStreamingCoalesceSchema,
ContextVisibilityModeSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,

View File

@@ -13,6 +13,10 @@ export {
export { logConfigUpdated } from "../config/logging.js";
export { updateConfig } from "../commands/models/shared.js";
export { resolveChannelModelOverride } from "../channels/model-overrides.js";
export {
resolveChannelContextVisibilityMode,
resolveDefaultContextVisibility,
} from "../config/context-visibility.js";
export { resolveMarkdownTableMode } from "../config/markdown-tables.js";
export {
resolveChannelGroupPolicy,
@@ -65,6 +69,7 @@ export type {
DiscordIntentsConfig,
DiscordSlashCommandConfig,
DmPolicy,
ContextVisibilityMode,
GroupPolicy,
MarkdownTableMode,
OpenClawConfig,

View File

@@ -1,6 +1,7 @@
// Public security/policy helpers for plugins that need shared trust and DM gating logic.
export * from "../security/channel-metadata.js";
export * from "../security/context-visibility.js";
export * from "../security/dm-policy-shared.js";
export * from "../security/external-content.js";
export * from "../security/safe-regex.js";

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
shouldIncludeSupplementalContext,
} from "./context-visibility.js";
describe("evaluateSupplementalContextVisibility", () => {
it("reports why all mode keeps context", () => {
expect(
evaluateSupplementalContextVisibility({
mode: "all",
kind: "history",
senderAllowed: false,
}),
).toEqual({
include: true,
reason: "mode_all",
});
});
it("reports quote override decisions", () => {
expect(
evaluateSupplementalContextVisibility({
mode: "allowlist_quote",
kind: "quote",
senderAllowed: false,
}),
).toEqual({
include: true,
reason: "quote_override",
});
});
});
describe("shouldIncludeSupplementalContext", () => {
it("keeps all context in all mode", () => {
expect(
shouldIncludeSupplementalContext({
mode: "all",
kind: "history",
senderAllowed: false,
}),
).toBe(true);
});
it("enforces allowlist mode for non-allowlisted senders", () => {
expect(
shouldIncludeSupplementalContext({
mode: "allowlist",
kind: "thread",
senderAllowed: false,
}),
).toBe(false);
});
it("keeps explicit quotes in allowlist_quote mode", () => {
expect(
shouldIncludeSupplementalContext({
mode: "allowlist_quote",
kind: "quote",
senderAllowed: false,
}),
).toBe(true);
});
it("still drops non-quote context in allowlist_quote mode", () => {
expect(
shouldIncludeSupplementalContext({
mode: "allowlist_quote",
kind: "history",
senderAllowed: false,
}),
).toBe(false);
});
});
describe("filterSupplementalContextItems", () => {
it("filters blocked items and reports omission count", () => {
const result = filterSupplementalContextItems({
items: [
{ id: "allowed", senderAllowed: true },
{ id: "blocked", senderAllowed: false },
],
mode: "allowlist",
kind: "thread",
isSenderAllowed: (item) => item.senderAllowed,
});
expect(result).toEqual({
items: [{ id: "allowed", senderAllowed: true }],
omitted: 1,
});
});
});

View File

@@ -0,0 +1,58 @@
import type { ContextVisibilityMode } from "../config/types.base.js";
export type ContextVisibilityKind = "history" | "thread" | "quote" | "forwarded";
export type ContextVisibilityDecisionReason =
| "mode_all"
| "sender_allowed"
| "quote_override"
| "blocked";
export type ContextVisibilityDecision = {
include: boolean;
reason: ContextVisibilityDecisionReason;
};
export function evaluateSupplementalContextVisibility(params: {
mode: ContextVisibilityMode;
kind: ContextVisibilityKind;
senderAllowed: boolean;
}): ContextVisibilityDecision {
if (params.mode === "all") {
return { include: true, reason: "mode_all" };
}
if (params.senderAllowed) {
return { include: true, reason: "sender_allowed" };
}
if (params.mode === "allowlist_quote" && params.kind === "quote") {
return { include: true, reason: "quote_override" };
}
return { include: false, reason: "blocked" };
}
export function shouldIncludeSupplementalContext(params: {
mode: ContextVisibilityMode;
kind: ContextVisibilityKind;
senderAllowed: boolean;
}): boolean {
return evaluateSupplementalContextVisibility(params).include;
}
export function filterSupplementalContextItems<T>(params: {
items: readonly T[];
mode: ContextVisibilityMode;
kind: ContextVisibilityKind;
isSenderAllowed: (item: T) => boolean;
}): { items: T[]; omitted: number } {
const items = params.items.filter((item) =>
shouldIncludeSupplementalContext({
mode: params.mode,
kind: params.kind,
senderAllowed: params.isSenderAllowed(item),
}),
);
return {
items,
omitted: params.items.length - items.length,
};
}