mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 05:31:30 +00:00
feat: add configurable context visibility
This commit is contained in:
19
SECURITY.md
19
SECURITY.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
If you want...
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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?
|
||||
|
||||
78
src/config/context-visibility.test.ts
Normal file
78
src/config/context-visibility.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
45
src/config/context-visibility.ts
Normal file
45
src/config/context-visibility.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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"'],
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
} from "../channels/plugins/config-schema.js";
|
||||
export {
|
||||
BlockStreamingCoalesceSchema,
|
||||
ContextVisibilityModeSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
95
src/security/context-visibility.test.ts
Normal file
95
src/security/context-visibility.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/security/context-visibility.ts
Normal file
58
src/security/context-visibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user