refactor: centralize channel ingress access

This commit is contained in:
Peter Steinberger
2026-05-10 05:06:03 +01:00
parent 1725eebe62
commit a0fb7fb045
250 changed files with 11410 additions and 8161 deletions

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
expandAllowFromWithAccessGroups,
resolveAccessGroupAllowFromState,
} from "./access-groups.js";
describe("access group allowlists", () => {
it("reports static, missing, unsupported, failed, and compatibility expansion states", async () => {
const cfg = {
accessGroups: {
admins: { type: "message.senders", members: { "*": ["global"], test: ["local"] } },
audience: { type: "discord.channelAudience", guildId: "guild-1", channelId: "channel-1" },
},
} as OpenClawConfig;
await expect(
resolveAccessGroupAllowFromState({
accessGroups: cfg.accessGroups,
allowFrom: ["accessGroup:admins", "accessGroup:missing", "accessGroup:audience"],
channel: "test",
accountId: "default",
senderId: "local",
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
}),
).resolves.toMatchObject({
referenced: ["admins", "missing", "audience"],
matched: ["admins"],
missing: ["missing"],
unsupported: ["audience"],
failed: [],
matchedAllowFromEntries: ["accessGroup:admins"],
hasReferences: true,
hasMatch: true,
});
await expect(
resolveAccessGroupAllowFromState({
accessGroups: cfg.accessGroups,
allowFrom: ["accessGroup:audience"],
channel: "discord",
accountId: "default",
senderId: "discord:123",
resolveMembership: async () => {
throw new Error("discord lookup failed");
},
}),
).resolves.toMatchObject({ referenced: ["audience"], failed: ["audience"], hasMatch: false });
await expect(
expandAllowFromWithAccessGroups({
cfg,
allowFrom: ["accessGroup:admins"],
channel: "test",
accountId: "default",
senderId: "local",
isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId),
}),
).resolves.toEqual(["accessGroup:admins", "local"]);
});
});

View File

@@ -1,8 +1,12 @@
import {
ACCESS_GROUP_ALLOW_FROM_PREFIX,
parseAccessGroupAllowFromEntry,
} from "../channels/allow-from.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { AccessGroupConfig } from "../config/types.access-groups.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
export { ACCESS_GROUP_ALLOW_FROM_PREFIX, parseAccessGroupAllowFromEntry };
export type AccessGroupMembershipResolver = (params: {
cfg: OpenClawConfig;
@@ -13,14 +17,24 @@ export type AccessGroupMembershipResolver = (params: {
senderId: string;
}) => boolean | Promise<boolean>;
export function parseAccessGroupAllowFromEntry(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) {
return null;
}
const name = trimmed.slice(ACCESS_GROUP_ALLOW_FROM_PREFIX.length).trim();
return name.length > 0 ? name : null;
}
export type AccessGroupMembershipLookup = (params: {
name: string;
group: AccessGroupConfig;
channel: ChannelId;
accountId: string;
senderId: string;
}) => boolean | Promise<boolean>;
export type ResolvedAccessGroupAllowFromState = {
referenced: string[];
matched: string[];
missing: string[];
unsupported: string[];
failed: string[];
matchedAllowFromEntries: string[];
hasReferences: boolean;
hasMatch: boolean;
};
function resolveMessageSenderGroupEntries(params: {
group: AccessGroupConfig;
@@ -32,6 +46,83 @@ function resolveMessageSenderGroupEntries(params: {
return [...(params.group.members["*"] ?? []), ...(params.group.members[params.channel] ?? [])];
}
export async function resolveAccessGroupAllowFromState(params: {
accessGroups?: Record<string, AccessGroupConfig>;
allowFrom: Array<string | number> | null | undefined;
channel: ChannelId;
accountId: string;
senderId: string;
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
resolveMembership?: AccessGroupMembershipLookup;
}): Promise<ResolvedAccessGroupAllowFromState> {
const names = Array.from(
new Set(
(params.allowFrom ?? [])
.map((entry) => parseAccessGroupAllowFromEntry(String(entry)))
.filter((entry): entry is string => entry != null),
),
);
const state: ResolvedAccessGroupAllowFromState = {
referenced: names,
matched: [],
missing: [],
unsupported: [],
failed: [],
matchedAllowFromEntries: [],
hasReferences: names.length > 0,
hasMatch: false,
};
const groups = params.accessGroups;
for (const name of names) {
const group = groups?.[name];
if (!group) {
state.missing.push(name);
continue;
}
const senderEntries = resolveMessageSenderGroupEntries({
group,
channel: params.channel,
});
if (
senderEntries.length > 0 &&
params.isSenderAllowed?.(params.senderId, senderEntries) === true
) {
state.matched.push(name);
continue;
}
if (!params.resolveMembership) {
if (group.type !== "message.senders") {
state.unsupported.push(name);
}
continue;
}
let allowed = false;
try {
allowed = await params.resolveMembership({
name,
group,
channel: params.channel,
accountId: params.accountId,
senderId: params.senderId,
});
} catch {
state.failed.push(name);
continue;
}
if (allowed) {
state.matched.push(name);
}
}
state.matchedAllowFromEntries = state.matched.map(
(name) => `${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`,
);
state.hasMatch = state.matchedAllowFromEntries.length > 0;
return state;
}
export async function resolveAccessGroupAllowFromMatches(params: {
cfg?: OpenClawConfig;
allowFrom: Array<string | number> | null | undefined;
@@ -42,60 +133,24 @@ export async function resolveAccessGroupAllowFromMatches(params: {
resolveMembership?: AccessGroupMembershipResolver;
}): Promise<string[]> {
const cfg = params.cfg;
const groups = cfg?.accessGroups;
if (!groups) {
return [];
}
const names = Array.from(
new Set(
(params.allowFrom ?? [])
.map((entry) => parseAccessGroupAllowFromEntry(String(entry)))
.filter((entry): entry is string => entry != null),
),
);
if (names.length === 0) {
return [];
}
const matched: string[] = [];
for (const name of names) {
const group = groups[name];
if (!group) {
continue;
}
const senderEntries = resolveMessageSenderGroupEntries({
group,
channel: params.channel,
});
if (
senderEntries.length > 0 &&
params.isSenderAllowed?.(params.senderId, senderEntries) === true
) {
matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`);
continue;
}
let allowed = false;
try {
allowed =
(await params.resolveMembership?.({
cfg,
name,
group,
channel: params.channel,
accountId: params.accountId,
senderId: params.senderId,
})) === true;
} catch {
allowed = false;
}
if (allowed) {
matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`);
}
}
return matched;
const resolveMembership = params.resolveMembership;
const state = await resolveAccessGroupAllowFromState({
accessGroups: cfg?.accessGroups,
allowFrom: params.allowFrom,
channel: params.channel,
accountId: params.accountId,
senderId: params.senderId,
isSenderAllowed: params.isSenderAllowed,
resolveMembership:
resolveMembership && cfg
? async (lookupParams) =>
await resolveMembership({
cfg,
...lookupParams,
})
: undefined,
});
return state.matchedAllowFromEntries;
}
export async function expandAllowFromWithAccessGroups(params: {

View File

@@ -0,0 +1 @@
export * from "../security/dm-policy-shared.js";

View File

@@ -0,0 +1,146 @@
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import type { AccessFacts } from "../channels/turn/types.js";
import {
resolveChannelMessageIngress,
type ChannelIngressIdentityDescriptor,
type ResolveChannelMessageIngressParams,
} from "./channel-ingress-runtime.js";
import { projectIngressAccessFacts } from "./channel-ingress.js";
const identity = {
primary: { normalize: (value) => value.trim().toLowerCase(), sensitivity: "pii" },
} satisfies ChannelIngressIdentityDescriptor;
async function resolve(input: Partial<ResolveChannelMessageIngressParams> = {}) {
return await resolveChannelMessageIngress({
channelId: "runtime-test",
accountId: "default",
identity,
subject: { stableId: "owner" },
conversation: { kind: "direct", id: "dm-1" },
event: { kind: "message", authMode: "inbound", mayPair: true },
policy: { dmPolicy: "allowlist", groupPolicy: "disabled", ...input.policy },
allowFrom: ["owner"],
...input,
});
}
describe("plugin-sdk/channel-ingress-runtime", () => {
it("omits projected command facts unless command policy was requested", async () => {
const normalMessage = await resolve();
expect(projectIngressAccessFacts(normalMessage.ingress).commands).toBeUndefined();
const commandMessage = await resolve({
command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true },
});
expect(projectIngressAccessFacts(commandMessage.ingress).commands).toMatchObject({
authorized: true,
authorizers: [],
useAccessGroups: true,
allowTextCommands: true,
});
});
it("keeps command authorizers required on public AccessFacts", () => {
expectTypeOf<NonNullable<AccessFacts["commands"]>["authorizers"]>().toEqualTypeOf<
Array<{ configured: boolean; allowed: boolean }>
>();
});
it("derives store allowlists, command auth, sender separation, and redaction", async () => {
const sender = "Secret-Sender@example.test";
const readStoreAllowFrom = vi.fn(async () => ["secret-sender@example.test"]);
const allowed = await resolve({
subject: { stableId: sender },
policy: { dmPolicy: "pairing", groupPolicy: "disabled" },
allowFrom: [],
readStoreAllowFrom,
command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true },
});
expect(readStoreAllowFrom).toHaveBeenCalledOnce();
expect(allowed.ingress).toMatchObject({ admission: "dispatch", decision: "allow" });
expect(allowed.commandAccess.authorized).toBe(true);
expect(JSON.stringify(allowed.state)).not.toContain(sender);
expect(JSON.stringify(allowed.ingress)).not.toContain(sender);
const blockedBeforeCommand = await resolve({
route: { id: "route:disabled", enabled: false },
command: { useAccessGroups: true, allowTextCommands: true, hasControlCommand: true },
});
expect(blockedBeforeCommand.ingress.reasonCode).toBe("route_blocked");
expect(blockedBeforeCommand.commandAccess.authorized).toBe(false);
const unauthorizedCommand = await resolve({
conversation: { kind: "group", id: "room-1" },
event: { kind: "message", authMode: "inbound", mayPair: false },
policy: {
dmPolicy: "pairing",
groupPolicy: "open",
groupAllowFromFallbackToAllowFrom: false,
},
command: {
useAccessGroups: true,
allowTextCommands: true,
hasControlCommand: true,
groupOwnerAllowFrom: "none",
commandGroupAllowFromFallbackToAllowFrom: false,
},
});
expect(unauthorizedCommand.ingress.reasonCode).toBe("control_command_unauthorized");
expect(unauthorizedCommand.senderAccess).toMatchObject({
decision: "allow",
reasonCode: "group_policy_open",
});
expect(unauthorizedCommand.commandAccess.shouldBlockControlCommand).toBe(true);
});
it("keeps normalized compatibility entries scoped to the intended identifier kind", async () => {
const prefixedIdentity = {
primary: {
key: "user-id",
normalizeEntry: (value) =>
value
.trim()
.toLowerCase()
.replace(/^users\//, "") || null,
normalizeSubject: (value) =>
value
.trim()
.toLowerCase()
.replace(/^users\//, ""),
},
aliases: [
{
key: "email",
kind: "plugin:test-email",
normalizeEntry(value) {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("users/") || !normalized.includes("@") ? null : normalized;
},
normalizeSubject: (value) => value.trim().toLowerCase(),
dangerous: true,
},
],
} satisfies ChannelIngressIdentityDescriptor;
const result = await resolveChannelMessageIngress({
channelId: "runtime-test",
accountId: "default",
identity: prefixedIdentity,
subject: { stableId: "users/123", aliases: { email: "jane@example.test" } },
conversation: { kind: "direct", id: "dm-1" },
event: { kind: "message", authMode: "inbound", mayPair: false },
policy: {
dmPolicy: "allowlist",
groupPolicy: "disabled",
mutableIdentifierMatching: "enabled",
},
allowFrom: ["users/jane@example.test"],
});
expect(result.senderAccess.effectiveAllowFrom).toEqual(["jane@example.test"]);
expect(result.senderAccess.decision).toBe("block");
});
});

View File

@@ -0,0 +1,44 @@
/**
* High-level runtime resolver for inbound channel access decisions.
*
* Channel plugins should use this subpath for new receive paths. It accepts
* platform facts, raw allowlists, route descriptors, command facts, and access
* group config, then returns sender/route/command/activation projections plus
* the ordered ingress graph.
*/
export {
channelIngressRoutes,
createChannelIngressResolver,
defineStableChannelIngressIdentity,
readChannelIngressStoreAllowFromForDmPolicy,
resolveChannelMessageIngress,
resolveStableChannelMessageIngress,
} from "../channels/message-access/index.js";
export type {
AccessGroupMembershipFact,
ChannelIngressDecision,
ChannelIngressAccessGroupMembershipResolver,
ChannelIngressCommandPresetInput,
ChannelIngressConfigInput,
ChannelIngressEventInput,
ChannelIngressEventPresetInput,
ChannelIngressIdentityDescriptor,
ChannelIngressIdentityAlias,
ChannelIngressIdentityField,
ChannelIngressIdentitySubjectInput,
ChannelIngressIdentifierKind,
ChannelIngressPolicyInput,
ChannelIngressRouteAccess,
ChannelIngressRouteDescriptor,
ChannelIngressResolver,
ChannelIngressResolverMessageParams,
ChannelIngressStateInput,
ChannelIngressState,
ChannelMessageIngressCommandInput,
CreateChannelIngressResolverParams,
IngressReasonCode,
ResolvedChannelMessageIngress,
ResolveChannelMessageIngressParams,
ResolveStableChannelMessageIngressParams,
StableChannelIngressIdentityParams,
} from "../channels/message-access/index.js";

View File

@@ -0,0 +1,620 @@
import {
decideChannelIngress,
resolveChannelIngressState as resolveChannelIngressStateInternal,
} from "../channels/message-access/index.js";
import type {
AccessGraphGate,
ChannelIngressDecision,
ChannelIngressIdentifierKind,
ChannelIngressPolicyInput,
ChannelIngressState,
ChannelIngressStateInput as MessageAccessChannelIngressStateInput,
IngressGateKind,
IngressGatePhase,
InternalChannelIngressAdapter,
InternalChannelIngressNormalizeResult,
InternalChannelIngressSubject,
InternalMatchMaterial,
InternalNormalizedEntry,
IngressReasonCode,
} from "../channels/message-access/index.js";
import type { AccessFacts, ChannelTurnAdmission } from "../channels/turn/types.js";
import type {
DmGroupAccessDecision,
DmGroupAccessReasonCode,
} from "../security/dm-policy-shared.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
export { decideChannelIngress };
export type {
AccessGraph,
AccessGraphGate,
AccessGroupMembershipFact,
ChannelIngressAdmission,
ChannelIngressChannelId,
ChannelIngressDecision,
ChannelIngressEventInput,
ChannelIngressIdentifierKind,
ChannelIngressNormalizedEntry,
ChannelIngressPolicyInput,
ChannelIngressState,
IngressGateEffect,
IngressGateKind,
IngressGatePhase,
IngressReasonCode,
MatchableIdentifier,
RedactedChannelIngressEvent,
RedactedIngressAllowlistFacts,
RedactedIngressEntryDiagnostic,
RedactedIngressMatch,
ResolvedIngressAllowlist,
ResolvedRouteGateFacts,
RouteGateFacts,
RouteGateState,
RouteSenderAllowlistSource,
RouteSenderPolicy,
} from "../channels/message-access/index.js";
export type ChannelIngressSubjectIdentifier = InternalMatchMaterial;
export type ChannelIngressSubject = InternalChannelIngressSubject;
export type ChannelIngressAdapterEntry = InternalNormalizedEntry;
export type ChannelIngressAdapterNormalizeResult = InternalChannelIngressNormalizeResult;
export type ChannelIngressAdapter = InternalChannelIngressAdapter;
export type ChannelIngressStateInput = MessageAccessChannelIngressStateInput;
declare const CHANNEL_INGRESS_PLUGIN_ID: unique symbol;
export type ChannelIngressPluginId = string & {
readonly [CHANNEL_INGRESS_PLUGIN_ID]: true;
};
export type ChannelIngressGateSelector = {
phase: IngressGatePhase;
kind: IngressGateKind;
};
export type ChannelIngressDecisionBundle = {
dm: ChannelIngressDecision;
group: ChannelIngressDecision;
dmCommand: ChannelIngressDecision;
groupCommand: ChannelIngressDecision;
};
export type ChannelIngressSideEffectResult =
| { kind: "none" }
| { kind: "pairing-reply-sent" }
| { kind: "pairing-reply-failed"; errorCode?: string }
| { kind: "command-reply-sent" }
| { kind: "command-reply-failed"; errorCode?: string }
| { kind: "pending-history-recorded" }
| { kind: "local-event-handled" };
export type RedactedIngressDiagnostics = {
decisiveGateId?: string;
reasonCode: IngressReasonCode;
};
export const CHANNEL_INGRESS_GATE_SELECTORS = {
command: { phase: "command", kind: "command" },
activation: { phase: "activation", kind: "mention" },
dmSender: { phase: "sender", kind: "dmSender" },
groupSender: { phase: "sender", kind: "groupSender" },
event: { phase: "event", kind: "event" },
} as const satisfies Record<string, ChannelIngressGateSelector>;
export type ChannelIngressSubjectIdentifierInput = {
value: string;
opaqueId?: string;
kind?: ChannelIngressIdentifierKind;
dangerous?: boolean;
sensitivity?: "normal" | "pii";
};
export type CreateChannelIngressStringAdapterParams = {
kind?: ChannelIngressIdentifierKind;
normalizeEntry?: (value: string) => string | null | undefined;
normalizeSubject?: (value: string) => string | null | undefined;
isWildcardEntry?: (value: string) => boolean;
resolveEntryId?: (params: { entry: string; index: number }) => string;
dangerous?: boolean | ((entry: string) => boolean);
sensitivity?: "normal" | "pii";
};
export type CreateChannelIngressMultiIdentifierAdapterParams = {
normalizeEntry: (entry: string, index: number) => readonly ChannelIngressAdapterEntry[];
getEntryMatchKey?: (entry: ChannelIngressAdapterEntry) => string | null | undefined;
getSubjectMatchKeys?: (
identifier: ChannelIngressSubjectIdentifier,
) => readonly (string | null | undefined)[];
isWildcardEntry?: (entry: ChannelIngressAdapterEntry) => boolean;
};
export type ChannelIngressDmGroupAccessProjection = {
decision: DmGroupAccessDecision;
reasonCode: DmGroupAccessReasonCode;
reason: string;
};
export type ChannelIngressSenderGroupAccessProjection = {
allowed: boolean;
groupPolicy: ChannelIngressPolicyInput["groupPolicy"];
providerMissingFallbackApplied: boolean;
reason: "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted";
};
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolveChannelIngressAccessParams = ChannelIngressStateInput & {
policy: ChannelIngressPolicyInput;
effectiveAllowFrom?: readonly string[];
effectiveGroupAllowFrom?: readonly string[];
};
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolvedChannelIngressAccess = {
state: ChannelIngressState;
ingress: ChannelIngressDecision;
isGroup: boolean;
senderReasonCode: IngressReasonCode;
access: ChannelIngressDmGroupAccessProjection & {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
};
commandAuthorized: boolean;
shouldBlockControlCommand: boolean;
};
function defaultNormalize(value: string): string {
return value;
}
function normalizeMatchValue(
value: string,
normalize: (value: string) => string | null | undefined,
): string | null {
const normalized = normalize(value);
return normalized == null ? null : normalized.trim() || null;
}
function resolveDangerous(
dangerous: CreateChannelIngressStringAdapterParams["dangerous"],
entry: string,
): boolean | undefined {
return typeof dangerous === "function" ? dangerous(entry) : dangerous;
}
function defaultIngressMatchKey(params: {
kind: ChannelIngressIdentifierKind;
value: string;
}): string {
return `${params.kind}:${params.value}`;
}
export function findChannelIngressGate(
decision: ChannelIngressDecision,
selector: ChannelIngressGateSelector,
): AccessGraphGate | undefined {
return decision.graph.gates.find(
(gate) => gate.phase === selector.phase && gate.kind === selector.kind,
);
}
export function findChannelIngressSenderGate(
decision: ChannelIngressDecision,
params: { isGroup: boolean },
): AccessGraphGate | undefined {
return findChannelIngressGate(
decision,
params.isGroup
? CHANNEL_INGRESS_GATE_SELECTORS.groupSender
: CHANNEL_INGRESS_GATE_SELECTORS.dmSender,
);
}
export function findChannelIngressCommandGate(
decision: ChannelIngressDecision,
): AccessGraphGate | undefined {
return findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command);
}
export function decideChannelIngressBundle(params: {
directState: ChannelIngressState;
groupState: ChannelIngressState;
basePolicy: ChannelIngressPolicyInput;
commandPolicy: ChannelIngressPolicyInput;
}): ChannelIngressDecisionBundle {
return {
dm: decideChannelIngress(params.directState, params.basePolicy),
group: decideChannelIngress(params.groupState, params.basePolicy),
dmCommand: decideChannelIngress(params.directState, params.commandPolicy),
groupCommand: decideChannelIngress(params.groupState, params.commandPolicy),
};
}
function projectGroupPolicy(
gate: AccessGraphGate | undefined,
): NonNullable<AccessFacts["group"]>["policy"] {
const policy = gate?.sender?.policy;
return policy === "open" || policy === "disabled" ? policy : "allowlist";
}
function projectMentionFacts(gate: AccessGraphGate | undefined): AccessFacts["mentions"] {
const activation = gate?.activation;
if (!activation?.hasMentionFacts) {
return undefined;
}
return {
canDetectMention: activation.canDetectMention ?? false,
wasMentioned: activation.wasMentioned ?? false,
hasAnyMention: activation.hasAnyMention,
implicitMentionKinds: activation.implicitMentionKinds
? [...activation.implicitMentionKinds]
: undefined,
requireMention: activation.requireMention,
effectiveWasMentioned: activation.effectiveWasMentioned,
shouldSkip: activation.shouldSkip,
};
}
function projectDmDecision(
decision: ChannelIngressDecision,
dmSender: AccessGraphGate | undefined,
): NonNullable<AccessFacts["dm"]>["decision"] {
if (decision.decision === "pairing") {
return "pairing";
}
if (dmSender) {
return dmSender.allowed ? "allow" : "deny";
}
return decision.admission === "drop" ? "deny" : "allow";
}
export function projectIngressAccessFacts(decision: ChannelIngressDecision): AccessFacts {
const command = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.command);
const activation = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.activation);
const dmSender = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.dmSender);
const groupSender = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.groupSender);
const event = findChannelIngressGate(decision, CHANNEL_INGRESS_GATE_SELECTORS.event);
return {
dm: {
decision: projectDmDecision(decision, dmSender),
reason: dmSender?.reasonCode ?? decision.reasonCode,
allowFrom: [],
allowlist: dmSender?.allowlist,
},
group: {
policy: projectGroupPolicy(groupSender),
routeAllowed: !decision.graph.gates.some(
(gate) => gate.phase === "route" && gate.effect === "block-dispatch",
),
senderAllowed: groupSender?.allowed ?? dmSender?.allowed ?? false,
allowFrom: [],
requireMention: activation?.activation?.requireMention ?? false,
allowlist: groupSender?.allowlist,
},
commands: command?.command
? {
authorized: command.allowed,
shouldBlockControlCommand: command.command.shouldBlockControlCommand,
reasonCode: command.reasonCode,
useAccessGroups: command.command.useAccessGroups,
allowTextCommands: command.command.allowTextCommands,
modeWhenAccessGroupsOff: command.command.modeWhenAccessGroupsOff,
authorizers: [],
}
: undefined,
event: event?.event
? {
...event.event,
authorized: event.allowed,
reasonCode: event.reasonCode,
}
: undefined,
mentions: projectMentionFacts(activation),
};
}
export function mapChannelIngressDecisionToTurnAdmission(
decision: ChannelIngressDecision,
sideEffect: ChannelIngressSideEffectResult,
): ChannelTurnAdmission {
if (decision.admission === "dispatch") {
return { kind: "dispatch", reason: decision.reasonCode };
}
if (decision.admission === "observe") {
return { kind: "observeOnly", reason: decision.reasonCode };
}
if (decision.admission === "pairing-required") {
return sideEffect.kind === "pairing-reply-sent"
? { kind: "handled", reason: decision.reasonCode }
: { kind: "drop", reason: decision.reasonCode };
}
if (decision.admission === "skip") {
return sideEffect.kind === "pending-history-recorded" ||
sideEffect.kind === "local-event-handled" ||
sideEffect.kind === "command-reply-sent"
? { kind: "handled", reason: decision.reasonCode }
: { kind: "drop", reason: decision.reasonCode, recordHistory: false };
}
return sideEffect.kind === "local-event-handled" || sideEffect.kind === "command-reply-sent"
? { kind: "handled", reason: decision.reasonCode }
: { kind: "drop", reason: decision.reasonCode };
}
export function createChannelIngressPluginId(id: string): ChannelIngressPluginId {
const trimmed = id.trim();
if (!trimmed) {
throw new Error("Channel ingress plugin id must be non-empty.");
}
return trimmed as ChannelIngressPluginId;
}
export function createChannelIngressSubject(
input:
| ChannelIngressSubjectIdentifierInput
| { identifiers: readonly ChannelIngressSubjectIdentifierInput[] },
): ChannelIngressSubject {
const identifiers = "identifiers" in input ? input.identifiers : [input];
return {
identifiers: identifiers.map((identifier, index) => ({
opaqueId: identifier.opaqueId ?? `subject-${index + 1}`,
kind: identifier.kind ?? "stable-id",
value: identifier.value,
dangerous: identifier.dangerous,
sensitivity: identifier.sensitivity,
})),
};
}
export function createChannelIngressStringAdapter(
params: CreateChannelIngressStringAdapterParams = {},
): ChannelIngressAdapter {
const kind = params.kind ?? "stable-id";
const normalizeEntry = params.normalizeEntry ?? defaultNormalize;
const normalizeSubject = params.normalizeSubject ?? normalizeEntry;
const isWildcardEntry = params.isWildcardEntry ?? ((entry: string) => entry === "*");
return {
normalizeEntries({ entries }) {
const matchable = normalizeStringEntries(entries).flatMap((entry, index) => {
const value = isWildcardEntry(entry) ? "*" : normalizeMatchValue(entry, normalizeEntry);
if (!value) {
return [];
}
return [
{
opaqueEntryId: params.resolveEntryId?.({ entry, index }) ?? `entry-${index + 1}`,
kind,
value,
dangerous: resolveDangerous(params.dangerous, entry),
sensitivity: params.sensitivity,
},
];
});
return {
matchable,
invalid: [],
disabled: [],
};
},
matchSubject({ subject, entries }) {
const values = new Set(
subject.identifiers.flatMap((identifier) => {
if (identifier.kind !== kind) {
return [];
}
const value = normalizeMatchValue(identifier.value, normalizeSubject);
return value ? [value] : [];
}),
);
const matchedEntryIds = entries
.filter((entry) => entry.kind === kind && (entry.value === "*" || values.has(entry.value)))
.map((entry) => entry.opaqueEntryId);
return {
matched: matchedEntryIds.length > 0,
matchedEntryIds,
};
},
};
}
export function createChannelIngressMultiIdentifierAdapter(
params: CreateChannelIngressMultiIdentifierAdapterParams,
): ChannelIngressAdapter {
const getEntryMatchKey = params.getEntryMatchKey ?? defaultIngressMatchKey;
const getSubjectMatchKeys =
params.getSubjectMatchKeys ??
((identifier: ChannelIngressSubjectIdentifier) => [defaultIngressMatchKey(identifier)]);
const isWildcardEntry = params.isWildcardEntry ?? ((entry) => entry.value === "*");
return {
normalizeEntries({ entries }) {
return {
matchable: entries.flatMap((entry, index) => params.normalizeEntry(entry, index)),
invalid: [],
disabled: [],
};
},
matchSubject({ subject, entries }) {
const subjectKeys = new Set(
subject.identifiers.flatMap((identifier) =>
getSubjectMatchKeys(identifier).filter((key): key is string => Boolean(key)),
),
);
const matchedEntryIds = entries
.filter((entry) => {
if (isWildcardEntry(entry)) {
return true;
}
const key = getEntryMatchKey(entry);
return key ? subjectKeys.has(key) : false;
})
.map((entry) => entry.opaqueEntryId);
return {
matched: matchedEntryIds.length > 0,
matchedEntryIds,
};
},
};
}
export function assertNeverChannelIngressReason(reasonCode: never): never {
throw new Error(`Unhandled channel ingress reason code: ${String(reasonCode)}`);
}
/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)` or typed gate selectors. */
export function findChannelIngressSenderReasonCode(
decision: ChannelIngressDecision,
params: { isGroup: boolean },
): IngressReasonCode {
return findChannelIngressSenderGate(decision, params)?.reasonCode ?? decision.reasonCode;
}
/** @deprecated Use `senderAccess.reasonCode` from `resolveChannelMessageIngress(...)`. */
export function mapChannelIngressReasonCodeToDmGroupAccessReason(params: {
reasonCode: IngressReasonCode;
isGroup: boolean;
}): DmGroupAccessReasonCode {
switch (params.reasonCode) {
case "group_policy_open":
case "group_policy_allowed":
return "group_policy_allowed";
case "group_policy_disabled":
return "group_policy_disabled";
case "route_sender_empty":
case "group_policy_empty_allowlist":
return "group_policy_empty_allowlist";
case "group_policy_not_allowlisted":
return "group_policy_not_allowlisted";
case "dm_policy_open":
return "dm_policy_open";
case "dm_policy_disabled":
return "dm_policy_disabled";
case "dm_policy_allowlisted":
return "dm_policy_allowlisted";
case "dm_policy_pairing_required":
return "dm_policy_pairing_required";
default:
return params.isGroup ? "group_policy_not_allowlisted" : "dm_policy_not_allowlisted";
}
}
/** @deprecated Use `senderAccess.reason` from `resolveChannelMessageIngress(...)`. */
export function formatChannelIngressPolicyReason(params: {
reasonCode: DmGroupAccessReasonCode;
dmPolicy: string;
groupPolicy: string;
}): string {
switch (params.reasonCode) {
case "group_policy_allowed":
return `groupPolicy=${params.groupPolicy}`;
case "group_policy_disabled":
return "groupPolicy=disabled";
case "group_policy_empty_allowlist":
return "groupPolicy=allowlist (empty allowlist)";
case "group_policy_not_allowlisted":
return "groupPolicy=allowlist (not allowlisted)";
case "dm_policy_open":
return "dmPolicy=open";
case "dm_policy_disabled":
return "dmPolicy=disabled";
case "dm_policy_allowlisted":
return `dmPolicy=${params.dmPolicy} (allowlisted)`;
case "dm_policy_pairing_required":
return "dmPolicy=pairing (not allowlisted)";
case "dm_policy_not_allowlisted":
return `dmPolicy=${params.dmPolicy} (not allowlisted)`;
}
const exhaustive: never = params.reasonCode;
return exhaustive;
}
/** @deprecated Use `senderAccess.groupAccess` from `resolveChannelMessageIngress(...)`. */
export function projectChannelIngressSenderGroupAccess(params: {
reasonCode: IngressReasonCode;
decisionAllowed: boolean;
groupPolicy: ChannelIngressPolicyInput["groupPolicy"];
providerMissingFallbackApplied?: boolean;
}): ChannelIngressSenderGroupAccessProjection {
const reasonCode = mapChannelIngressReasonCodeToDmGroupAccessReason({
reasonCode: params.reasonCode,
isGroup: true,
});
const reason =
params.groupPolicy === "disabled" || reasonCode === "group_policy_disabled"
? "disabled"
: reasonCode === "group_policy_empty_allowlist"
? "empty_allowlist"
: reasonCode === "group_policy_not_allowlisted"
? "sender_not_allowlisted"
: "allowed";
return {
allowed: reason === "allowed" && params.decisionAllowed,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: params.providerMissingFallbackApplied ?? false,
reason,
};
}
/** @deprecated Use `senderAccess` from `resolveChannelMessageIngress(...)`. */
export function projectChannelIngressDmGroupAccess(params: {
ingress: ChannelIngressDecision;
isGroup: boolean;
dmPolicy: string;
groupPolicy: string;
}): ChannelIngressDmGroupAccessProjection {
const reasonCode = mapChannelIngressReasonCodeToDmGroupAccessReason({
reasonCode: findChannelIngressSenderReasonCode(params.ingress, { isGroup: params.isGroup }),
isGroup: params.isGroup,
});
const decision: DmGroupAccessDecision =
reasonCode === "dm_policy_pairing_required"
? "pairing"
: params.ingress.decision === "allow"
? "allow"
: "block";
const reason = formatChannelIngressPolicyReason({
reasonCode,
dmPolicy: params.dmPolicy,
groupPolicy: params.groupPolicy,
});
return {
decision,
reasonCode,
reason,
};
}
export async function resolveChannelIngressState(
input: ChannelIngressStateInput,
): Promise<ChannelIngressState> {
return await resolveChannelIngressStateInternal(input);
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveChannelIngressAccess(
params: ResolveChannelIngressAccessParams,
): Promise<ResolvedChannelIngressAccess> {
const { policy, effectiveAllowFrom, effectiveGroupAllowFrom, ...stateInput } = params;
const state = await resolveChannelIngressState(stateInput);
const ingress = decideChannelIngress(state, policy);
const isGroup = params.conversation.kind !== "direct";
const senderReasonCode = findChannelIngressSenderReasonCode(ingress, { isGroup });
const access = projectChannelIngressDmGroupAccess({
ingress,
isGroup,
dmPolicy: policy.dmPolicy,
groupPolicy: policy.groupPolicy,
});
const commandGate = findChannelIngressCommandGate(ingress);
return {
state,
ingress,
isGroup,
senderReasonCode,
access: {
...access,
effectiveAllowFrom: [...(effectiveAllowFrom ?? [])],
effectiveGroupAllowFrom: [...(effectiveGroupAllowFrom ?? [])],
},
commandAuthorized: commandGate?.allowed === true,
shouldBlockControlCommand: commandGate?.command?.shouldBlockControlCommand === true,
};
}

View File

@@ -50,7 +50,7 @@ export {
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
resolveOpenDmAllowlistAccess,
} from "../security/dm-policy-shared.js";
} from "./channel-access-compat.js";
export {
evaluateGroupRouteAccessForPolicy,
evaluateSenderGroupAccessForPolicy,

View File

@@ -2,12 +2,26 @@ export {
buildCommandTextFromArgs,
findCommandByNativeName,
formatCommandArgMenuTitle,
listChatCommands,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
maybeResolveTextAlias,
normalizeCommandBody,
parseCommandArgs,
serializeCommandArgs,
resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js";
export type { CommandArgs } from "../auto-reply/commands-registry.js";
export type {
ChatCommandDefinition,
CommandArgDefinition,
CommandArgValues,
CommandArgs,
NativeCommandSpec,
} from "../auto-reply/commands-registry.js";
export {
hasControlCommand,
shouldComputeCommandAuthorized,
} from "../auto-reply/command-detection.js";
export {
resolveCommandAuthorizedFromAuthorizers,
resolveControlCommandGate,
@@ -18,3 +32,6 @@ export {
type CommandAuthorization,
} from "../auto-reply/command-auth.js";
export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js";
export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js";
export { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
export { listProviderPluginCommandSpecs } from "../plugins/command-specs.js";

View File

@@ -5,17 +5,20 @@ import {
} from "../auto-reply/command-status-builders.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js";
import {
expandAllowFromWithAccessGroups,
type AccessGroupMembershipResolver,
} from "./access-groups.js";
import { resolveDmGroupAccessWithLists } from "./channel-access-compat.js";
export {
ACCESS_GROUP_ALLOW_FROM_PREFIX,
expandAllowFromWithAccessGroups,
parseAccessGroupAllowFromEntry,
resolveAccessGroupAllowFromMatches,
resolveAccessGroupAllowFromState,
type AccessGroupMembershipResolver,
type AccessGroupMembershipLookup,
type ResolvedAccessGroupAllowFromState,
} from "./access-groups.js";
export { buildCommandsPaginationKeyboard } from "./telegram-command-ui.js";
export {
@@ -100,6 +103,7 @@ export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js"
export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js";
export type { StoredModelOverride } from "../auto-reply/reply/stored-model-override.js";
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolveSenderCommandAuthorizationParams = {
cfg: OpenClawConfig;
rawBody: string;
@@ -114,12 +118,14 @@ export type ResolveSenderCommandAuthorizationParams = {
resolveAccessGroupMembership?: AccessGroupMembershipResolver;
readAllowFromStore: () => Promise<string[]>;
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
resolveCommandAuthorizedFromAuthorizers: (params: {
/** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}) => boolean;
};
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type CommandAuthorizationRuntime = {
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
resolveCommandAuthorizedFromAuthorizers: (params: {
@@ -128,6 +134,7 @@ export type CommandAuthorizationRuntime = {
}) => boolean;
};
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit<
ResolveSenderCommandAuthorizationParams,
"shouldComputeCommandAuthorized" | "resolveCommandAuthorizedFromAuthorizers"
@@ -135,7 +142,7 @@ export type ResolveSenderCommandAuthorizationWithRuntimeParams = Omit<
runtime: CommandAuthorizationRuntime;
};
/** Fast-path DM command authorization when only policy and sender allowlist state matter. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveDirectDmAuthorizationOutcome(params: {
isGroup: boolean;
dmPolicy: string;
@@ -153,7 +160,7 @@ export function resolveDirectDmAuthorizationOutcome(params: {
return "allowed";
}
/** Runtime-backed wrapper around sender command authorization for grouped helper surfaces. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveSenderCommandAuthorizationWithRuntime(
params: ResolveSenderCommandAuthorizationWithRuntimeParams,
): ReturnType<typeof resolveSenderCommandAuthorization> {
@@ -164,7 +171,7 @@ export async function resolveSenderCommandAuthorizationWithRuntime(
});
}
/** Compute effective allowlists and command authorization for one inbound sender. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveSenderCommandAuthorization(
params: ResolveSenderCommandAuthorizationParams,
): Promise<{
@@ -236,13 +243,13 @@ export async function resolveSenderCommandAuthorization(
const ownerAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom);
const groupAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveGroupAllowFrom);
const commandAuthorized = shouldComputeAuth
? params.resolveCommandAuthorizedFromAuthorizers({
? (params.resolveCommandAuthorizedFromAuthorizers?.({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
}) ?? senderAllowedForCommands)
: undefined;
return {

View File

@@ -109,4 +109,4 @@ export {
resolvePluginConversationBindingApproval,
toPluginConversationBinding,
} from "../plugins/conversation-binding.js";
export { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
export { resolvePinnedMainDmOwnerFromAllowlist } from "./channel-access-compat.js";

View File

@@ -1,25 +1,27 @@
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
type DmGroupAccessReasonCode,
} from "../security/dm-policy-shared.js";
import {
expandAllowFromWithAccessGroups,
type AccessGroupMembershipResolver,
} from "./access-groups.js";
import { DM_GROUP_ACCESS_REASON, type DmGroupAccessReasonCode } from "./channel-access-compat.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "./channel-access-compat.js";
export type { AccessGroupMembershipResolver } from "./access-groups.js";
export type DirectDmCommandAuthorizationRuntime = {
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
resolveCommandAuthorizedFromAuthorizers: (params: {
/** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
}) => boolean;
};
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolvedInboundDirectDmAccess = {
access: {
decision: "allow" | "block" | "pairing";
@@ -32,7 +34,20 @@ export type ResolvedInboundDirectDmAccess = {
commandAuthorized: boolean | undefined;
};
/** Resolve direct-DM policy, effective allowlists, and optional command auth in one place. */
function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode {
switch (reasonCode) {
case DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN:
case DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED:
case DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED:
case DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED:
case DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED:
return reasonCode;
default:
return DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED;
}
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveInboundDirectDmAccessWithRuntime(params: {
cfg: OpenClawConfig;
channel: ChannelId;
@@ -48,6 +63,10 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
readStoreAllowFrom?: (provider: ChannelId, accountId: string) => Promise<string[]>;
}): Promise<ResolvedInboundDirectDmAccess> {
const dmPolicy = params.dmPolicy ?? "pairing";
const shouldComputeAuth = params.runtime.shouldComputeCommandAuthorized(
params.rawBody,
params.cfg,
);
const storeAllowFrom =
dmPolicy === "pairing"
? await readStoreAllowFromForDmPolicy({
@@ -77,7 +96,6 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
resolveMembership: params.resolveAccessGroupMembership,
}),
]);
const access = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy,
@@ -86,17 +104,13 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowEntries) => params.isSenderAllowed(params.senderId, allowEntries),
});
const shouldComputeAuth = params.runtime.shouldComputeCommandAuthorized(
params.rawBody,
params.cfg,
);
const reasonCode = toLegacyDmReasonCode(access.reasonCode);
const senderAllowedForCommands = params.isSenderAllowed(
params.senderId,
access.effectiveAllowFrom,
);
const commandAuthorized = shouldComputeAuth
? params.runtime.resolveCommandAuthorizedFromAuthorizers({
? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({
useAccessGroups: params.cfg.commands?.useAccessGroups !== false,
authorizers: [
{
@@ -105,13 +119,13 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
},
],
modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff,
})
}) ?? senderAllowedForCommands)
: undefined;
return {
access: {
decision: access.decision,
reasonCode: access.reasonCode,
reasonCode,
reason: access.reason,
effectiveAllowFrom: access.effectiveAllowFrom,
},
@@ -121,7 +135,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
};
}
/** Convert resolved DM policy into a pre-crypto allow/block/pairing callback. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function createPreCryptoDirectDmAuthorizer(params: {
resolveAccess: (
senderId: string,

View File

@@ -9,41 +9,36 @@ export type SenderGroupAccessReason =
| "disabled"
| "empty_allowlist"
| "sender_not_allowlisted";
export type SenderGroupAccessDecision = {
allowed: boolean;
groupPolicy: GroupPolicy;
providerMissingFallbackApplied: boolean;
reason: SenderGroupAccessReason;
};
export type GroupRouteAccessReason =
| "allowed"
| "disabled"
| "empty_allowlist"
| "route_not_allowlisted"
| "route_disabled";
export type GroupRouteAccessDecision = {
allowed: boolean;
groupPolicy: GroupPolicy;
reason: GroupRouteAccessReason;
};
export type MatchedGroupAccessReason =
| "allowed"
| "disabled"
| "missing_match_input"
| "empty_allowlist"
| "not_allowlisted";
export type MatchedGroupAccessDecision = {
allowed: boolean;
groupPolicy: GroupPolicy;
reason: MatchedGroupAccessReason;
};
/** Downgrade sender-scoped group policy to open mode when no allowlist is configured. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveSenderScopedGroupPolicy(params: {
groupPolicy: GroupPolicy;
groupAllowFrom: string[];
@@ -54,7 +49,7 @@ export function resolveSenderScopedGroupPolicy(params: {
return params.groupAllowFrom.length > 0 ? "allowlist" : "open";
}
/** Evaluate route-level group access after policy, route match, and enablement checks. */
/** @deprecated Use route descriptors with `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function evaluateGroupRouteAccessForPolicy(params: {
groupPolicy: GroupPolicy;
routeAllowlistConfigured: boolean;
@@ -62,46 +57,23 @@ export function evaluateGroupRouteAccessForPolicy(params: {
routeEnabled?: boolean;
}): GroupRouteAccessDecision {
if (params.groupPolicy === "disabled") {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "disabled",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" };
}
if (params.routeMatched && params.routeEnabled === false) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "route_disabled",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "route_disabled" };
}
if (params.groupPolicy === "allowlist") {
if (!params.routeAllowlistConfigured) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "empty_allowlist",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" };
}
if (!params.routeMatched) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "route_not_allowlisted",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "route_not_allowlisted" };
}
}
return {
allowed: true,
groupPolicy: params.groupPolicy,
reason: "allowed",
};
return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" };
}
/** Evaluate generic allowlist match state for channels that compare derived group identifiers. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function evaluateMatchedGroupAccessForPolicy(params: {
groupPolicy: GroupPolicy;
allowlistConfigured: boolean;
@@ -110,45 +82,23 @@ export function evaluateMatchedGroupAccessForPolicy(params: {
hasMatchInput?: boolean;
}): MatchedGroupAccessDecision {
if (params.groupPolicy === "disabled") {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "disabled",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "disabled" };
}
if (params.groupPolicy === "allowlist") {
if (params.requireMatchInput && !params.hasMatchInput) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "missing_match_input",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "missing_match_input" };
}
if (!params.allowlistConfigured) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "empty_allowlist",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "empty_allowlist" };
}
if (!params.allowlistMatched) {
return {
allowed: false,
groupPolicy: params.groupPolicy,
reason: "not_allowlisted",
};
return { allowed: false, groupPolicy: params.groupPolicy, reason: "not_allowlisted" };
}
}
return {
allowed: true,
groupPolicy: params.groupPolicy,
reason: "allowed",
};
return { allowed: true, groupPolicy: params.groupPolicy, reason: "allowed" };
}
/** Evaluate sender access for an already-resolved group policy and allowlist. */
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function evaluateSenderGroupAccessForPolicy(params: {
groupPolicy: GroupPolicy;
providerMissingFallbackApplied?: boolean;
@@ -156,11 +106,12 @@ export function evaluateSenderGroupAccessForPolicy(params: {
senderId: string;
isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
}): SenderGroupAccessDecision {
const providerMissingFallbackApplied = Boolean(params.providerMissingFallbackApplied);
if (params.groupPolicy === "disabled") {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
providerMissingFallbackApplied,
reason: "disabled",
};
}
@@ -169,7 +120,7 @@ export function evaluateSenderGroupAccessForPolicy(params: {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
providerMissingFallbackApplied,
reason: "empty_allowlist",
};
}
@@ -177,21 +128,20 @@ export function evaluateSenderGroupAccessForPolicy(params: {
return {
allowed: false,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
providerMissingFallbackApplied,
reason: "sender_not_allowlisted",
};
}
}
return {
allowed: true,
groupPolicy: params.groupPolicy,
providerMissingFallbackApplied: Boolean(params.providerMissingFallbackApplied),
providerMissingFallbackApplied,
reason: "allowed",
};
}
/** Resolve provider fallback policy first, then evaluate sender access against that result. */
/** @deprecated Use `resolveOpenProviderRuntimeGroupPolicy` plus `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function evaluateSenderGroupAccess(params: {
providerConfigPresent: boolean;
configuredGroupPolicy?: GroupPolicy;

View File

@@ -4,7 +4,7 @@ type RouteLike = {
};
type RoutePeerLike = {
kind: string;
kind: "direct" | "group" | "channel";
id: string | number;
};

View File

@@ -8,13 +8,16 @@ export * from "../secrets/shared.js";
export type * from "../secrets/target-registry-types.js";
export * from "../security/channel-metadata.js";
export * from "../security/context-visibility.js";
export * from "../security/dm-policy-shared.js";
export * from "./channel-access-compat.js";
export {
ACCESS_GROUP_ALLOW_FROM_PREFIX,
expandAllowFromWithAccessGroups,
parseAccessGroupAllowFromEntry,
resolveAccessGroupAllowFromMatches,
resolveAccessGroupAllowFromState,
type AccessGroupMembershipResolver,
type AccessGroupMembershipLookup,
type ResolvedAccessGroupAllowFromState,
} from "./access-groups.js";
export * from "../security/external-content.js";
export * from "../security/safe-regex.js";

View File

@@ -265,7 +265,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
OriginatingChannel: params.channel,
OriginatingTo: params.reply.originatingTo,
CommandAuthorized: params.access?.commands
? params.access.commands.authorizers.some((entry) => entry.allowed)
? (params.access.commands.authorized ??
params.access.commands.authorizers?.some((entry) => entry.allowed) ??
false)
: false,
...params.extra,
}) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
@@ -636,6 +638,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
},
turn: {
run: runChannelTurnMock,
runAssembled:
dispatchAssembledChannelTurnMock as unknown as PluginRuntime["channel"]["turn"]["runAssembled"],
runResolved: vi.fn(
async (params: Parameters<PluginRuntime["channel"]["turn"]["runResolved"]>[0]) =>
await runChannelTurnMock({