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

@@ -1,5 +1,16 @@
import { normalizeStringEntries } from "../shared/string-normalization.js";
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
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 function mergeDmAllowFromSources(params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: Array<string | number>;

View File

@@ -0,0 +1,135 @@
import type {
ChannelIngressPolicyInput,
ChannelIngressState,
IngressReasonCode,
RedactedIngressAllowlistFacts,
RedactedIngressEntryDiagnostic,
ResolvedIngressAllowlist,
} from "./types.js";
export function allowlistFailureReason(
allowlist: ResolvedIngressAllowlist,
): IngressReasonCode | null {
if (allowlist.accessGroups.failed.length > 0) {
return "access_group_failed";
}
if (allowlist.accessGroups.unsupported.length > 0) {
return "access_group_unsupported";
}
if (allowlist.accessGroups.missing.length > 0) {
return "access_group_missing";
}
return null;
}
export function redactedAllowlistDiagnostics(
allowlist: ResolvedIngressAllowlist,
reasonCode: IngressReasonCode,
): RedactedIngressAllowlistFacts {
return {
configured: allowlist.hasConfiguredEntries,
matched: allowlist.match.matched,
reasonCode,
matchedEntryIds: allowlist.matchedEntryIds,
invalidEntryCount: allowlist.invalidEntries.length,
disabledEntryCount: allowlist.disabledEntries.length,
accessGroups: allowlist.accessGroups,
};
}
function uniqueStrings(values: readonly string[]): string[] {
return Array.from(new Set(values));
}
function mergeResolvedAllowlists(
allowlists: readonly ResolvedIngressAllowlist[],
): ResolvedIngressAllowlist {
const matches = allowlists.map((allowlist) => allowlist.match);
const matchedEntryIds = uniqueStrings(
allowlists.flatMap((allowlist) => allowlist.matchedEntryIds),
);
return {
rawEntryCount: allowlists.reduce((sum, allowlist) => sum + allowlist.rawEntryCount, 0),
normalizedEntries: allowlists.flatMap((allowlist) => allowlist.normalizedEntries),
invalidEntries: allowlists.flatMap((allowlist) => allowlist.invalidEntries),
disabledEntries: allowlists.flatMap((allowlist) => allowlist.disabledEntries),
matchedEntryIds,
hasConfiguredEntries: allowlists.some((allowlist) => allowlist.hasConfiguredEntries),
hasMatchableEntries: allowlists.some((allowlist) => allowlist.hasMatchableEntries),
hasWildcard: allowlists.some((allowlist) => allowlist.hasWildcard),
accessGroups: {
referenced: uniqueStrings(
allowlists.flatMap((allowlist) => allowlist.accessGroups.referenced),
),
matched: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.matched)),
missing: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.missing)),
unsupported: uniqueStrings(
allowlists.flatMap((allowlist) => allowlist.accessGroups.unsupported),
),
failed: uniqueStrings(allowlists.flatMap((allowlist) => allowlist.accessGroups.failed)),
},
match: {
matched: matches.some((match) => match.matched) || matchedEntryIds.length > 0,
matchedEntryIds,
},
};
}
export function applyMutableIdentifierPolicy(
allowlist: ResolvedIngressAllowlist,
policy: ChannelIngressPolicyInput,
): ResolvedIngressAllowlist {
if (policy.mutableIdentifierMatching === "enabled") {
return allowlist;
}
const dangerousEntryIds = new Set(
allowlist.normalizedEntries
.filter((entry) => entry.dangerous)
.map((entry) => entry.opaqueEntryId),
);
if (dangerousEntryIds.size === 0) {
return allowlist;
}
const matchedEntryIds = allowlist.matchedEntryIds.filter((id) => !dangerousEntryIds.has(id));
const disabledEntries: RedactedIngressEntryDiagnostic[] = [
...allowlist.disabledEntries,
...allowlist.normalizedEntries
.filter((entry) => entry.dangerous)
.map((entry) => ({
opaqueEntryId: entry.opaqueEntryId,
reasonCode: "mutable_identifier_disabled" as const,
})),
];
return {
...allowlist,
disabledEntries,
matchedEntryIds,
hasMatchableEntries: allowlist.normalizedEntries.some((entry) => !entry.dangerous),
match: {
matched: matchedEntryIds.length > 0,
matchedEntryIds,
},
};
}
export function effectiveGroupSenderAllowlist(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
}): ResolvedIngressAllowlist {
let effective =
params.policy.groupAllowFromFallbackToAllowFrom &&
!params.state.allowlists.group.hasConfiguredEntries
? params.state.allowlists.dm
: params.state.allowlists.group;
for (const route of params.state.routeFacts) {
if (route.gate !== "matched" || !route.senderAllowlist) {
continue;
}
if (route.senderPolicy === "inherit") {
effective = mergeResolvedAllowlists([effective, route.senderAllowlist]);
continue;
}
effective = route.senderAllowlist;
}
return applyMutableIdentifierPolicy(effective, params.policy);
}

View File

@@ -0,0 +1,327 @@
import { resolveCommandAuthorizedFromAuthorizers } from "../command-gating.js";
import { resolveInboundMentionDecision } from "../mention-gating.js";
import { applyMutableIdentifierPolicy, redactedAllowlistDiagnostics } from "./allowlist.js";
import {
applyEventAuthModeToSenderGate,
senderGateForDirect,
senderGateForGroup,
} from "./sender-gates.js";
import type {
AccessGraphGate,
ChannelIngressDecision,
ChannelIngressPolicyInput,
ChannelIngressState,
RedactedIngressMatch,
} from "./types.js";
function decisiveDecision(params: {
admission: ChannelIngressDecision["admission"];
decision: ChannelIngressDecision["decision"];
gate: AccessGraphGate;
gates: AccessGraphGate[];
}): ChannelIngressDecision {
return {
admission: params.admission,
decision: params.decision,
decisiveGateId: params.gate.id,
reasonCode: params.gate.reasonCode,
graph: { gates: params.gates },
};
}
function routeGates(state: ChannelIngressState): AccessGraphGate[] {
return state.routeFacts.map((route) => ({
id: route.id,
phase: "route",
kind: route.kind,
effect: route.effect,
allowed: route.effect !== "block-dispatch",
reasonCode: route.effect === "block-dispatch" ? "route_blocked" : "allowed",
match: route.match,
}));
}
function routeSenderEmptyGate(state: ChannelIngressState): AccessGraphGate | null {
const route = state.routeFacts.find(
(fact) =>
fact.senderPolicy === "deny-when-empty" &&
fact.gate === "matched" &&
fact.senderAllowlist?.hasConfiguredEntries !== true,
);
if (!route) {
return null;
}
const reasonCode = "route_sender_empty";
return {
id: `${route.id}:sender`,
phase: "route",
kind: "routeSender",
effect: "block-dispatch",
allowed: false,
reasonCode,
match: route.match,
allowlist: route.senderAllowlist
? redactedAllowlistDiagnostics(route.senderAllowlist, reasonCode)
: undefined,
};
}
function commandGate(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
}): AccessGraphGate {
const command = params.policy.command;
if (!command) {
return {
id: "command",
phase: "command",
kind: "command",
effect: "allow",
allowed: true,
reasonCode: "command_authorized",
};
}
const useAccessGroups = command.useAccessGroups ?? true;
const owner = applyMutableIdentifierPolicy(params.state.allowlists.commandOwner, params.policy);
const group = applyMutableIdentifierPolicy(params.state.allowlists.commandGroup, params.policy);
const authorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
modeWhenAccessGroupsOff: command.modeWhenAccessGroupsOff,
authorizers: [
{ configured: owner.hasConfiguredEntries, allowed: owner.match.matched },
{ configured: group.hasConfiguredEntries, allowed: group.match.matched },
],
});
const shouldBlock = command.allowTextCommands && command.hasControlCommand && !authorized;
return {
id: "command",
phase: "command",
kind: "command",
effect: shouldBlock ? "block-command" : "allow",
allowed: authorized,
reasonCode: shouldBlock ? "control_command_unauthorized" : "command_authorized",
match: mergeCommandMatch(owner.match, group.match),
command: {
useAccessGroups,
allowTextCommands: command.allowTextCommands,
modeWhenAccessGroupsOff: command.modeWhenAccessGroupsOff,
shouldBlockControlCommand: shouldBlock,
},
};
}
function mergeCommandMatch(
owner: RedactedIngressMatch,
group: RedactedIngressMatch,
): RedactedIngressMatch {
const matchedEntryIds = Array.from(new Set([...owner.matchedEntryIds, ...group.matchedEntryIds]));
return {
matched: owner.matched || group.matched || matchedEntryIds.length > 0,
matchedEntryIds,
};
}
function eventGate(params: {
state: ChannelIngressState;
senderGate: AccessGraphGate;
commandGate: AccessGraphGate;
}): AccessGraphGate {
const authMode = params.state.event.authMode;
const event = params.state.event;
const eventResult = (
allowed: boolean,
reasonCode: AccessGraphGate["reasonCode"],
): AccessGraphGate => ({
id: "event",
phase: "event",
kind: "event",
effect: allowed ? "allow" : "block-dispatch",
allowed,
reasonCode,
event,
});
if (authMode === "none" || authMode === "route-only") {
return eventResult(true, "event_authorized");
}
if (authMode === "command") {
return eventResult(
params.commandGate.allowed,
params.commandGate.allowed ? "event_authorized" : "event_unauthorized",
);
}
if (authMode === "origin-subject") {
if (!params.state.event.hasOriginSubject) {
return eventResult(false, "origin_subject_missing");
}
const matched = params.state.event.originSubjectMatched;
return eventResult(matched, matched ? "event_authorized" : "origin_subject_not_matched");
}
return eventResult(
params.senderGate.allowed,
params.senderGate.allowed ? "event_authorized" : "event_unauthorized",
);
}
function activationMetadata(params: {
activation?: ChannelIngressPolicyInput["activation"];
mentionFacts: ChannelIngressState["mentionFacts"];
shouldSkip: boolean;
effectiveWasMentioned?: boolean;
shouldBypassMention?: boolean;
}) {
const mentionFacts = params.mentionFacts;
return {
hasMentionFacts: mentionFacts != null,
requireMention: params.activation?.requireMention ?? false,
allowTextCommands: params.activation?.allowTextCommands ?? false,
...(params.activation?.allowedImplicitMentionKinds !== undefined
? { allowedImplicitMentionKinds: params.activation.allowedImplicitMentionKinds }
: {}),
...(params.activation?.order ? { order: params.activation.order } : {}),
shouldSkip: params.shouldSkip,
...(mentionFacts?.canDetectMention !== undefined
? { canDetectMention: mentionFacts.canDetectMention }
: {}),
...(mentionFacts?.wasMentioned !== undefined
? { wasMentioned: mentionFacts.wasMentioned }
: {}),
...(mentionFacts?.hasAnyMention !== undefined
? { hasAnyMention: mentionFacts.hasAnyMention }
: {}),
...(mentionFacts?.implicitMentionKinds !== undefined
? { implicitMentionKinds: mentionFacts.implicitMentionKinds }
: {}),
...(params.effectiveWasMentioned !== undefined
? { effectiveWasMentioned: params.effectiveWasMentioned }
: {}),
...(params.shouldBypassMention !== undefined
? { shouldBypassMention: params.shouldBypassMention }
: {}),
};
}
function activationGate(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
commandGate: AccessGraphGate;
}): AccessGraphGate {
const activation = params.policy.activation;
const mentionFacts = params.state.mentionFacts;
const activationResult = (input: {
shouldSkip: boolean;
effectiveWasMentioned?: boolean;
shouldBypassMention?: boolean;
}): AccessGraphGate => ({
id: "activation",
phase: "activation",
kind: "mention",
effect: input.shouldSkip ? "skip" : "allow",
allowed: !input.shouldSkip,
reasonCode: input.shouldSkip ? "activation_skipped" : "activation_allowed",
activation: activationMetadata({
activation,
mentionFacts,
shouldSkip: input.shouldSkip,
effectiveWasMentioned: input.effectiveWasMentioned,
shouldBypassMention: input.shouldBypassMention,
}),
});
if (!activation || !mentionFacts) {
return activationResult({
shouldSkip: false,
effectiveWasMentioned:
mentionFacts &&
(mentionFacts.wasMentioned || Boolean(mentionFacts.implicitMentionKinds?.length)),
});
}
const result = resolveInboundMentionDecision({
facts: mentionFacts,
policy: {
isGroup: params.state.conversationKind !== "direct",
requireMention: activation.requireMention,
allowedImplicitMentionKinds: activation.allowedImplicitMentionKinds,
allowTextCommands: activation.allowTextCommands,
hasControlCommand: params.policy.command?.hasControlCommand ?? false,
commandAuthorized: params.commandGate.allowed,
},
});
return activationResult({
shouldSkip: result.shouldSkip,
effectiveWasMentioned: result.effectiveWasMentioned,
shouldBypassMention: result.shouldBypassMention,
});
}
export function decideChannelIngress(
state: ChannelIngressState,
policy: ChannelIngressPolicyInput,
): ChannelIngressDecision {
const gates: AccessGraphGate[] = routeGates(state);
const emptyRouteSenderGate = routeSenderEmptyGate(state);
if (emptyRouteSenderGate) {
gates.push(emptyRouteSenderGate);
}
const routeBlock = gates.find((entry) => entry.effect === "block-dispatch");
if (routeBlock) {
return decisiveDecision({ admission: "drop", decision: "block", gate: routeBlock, gates });
}
const activationBeforeSender =
policy.activation?.order === "before-sender" && !policy.activation.allowTextCommands
? activationGate({
state,
policy,
commandGate: commandGate({ state, policy: { ...policy, command: undefined } }),
})
: null;
if (activationBeforeSender) {
gates.push(activationBeforeSender);
if (activationBeforeSender.effect === "skip") {
return decisiveDecision({
admission: "skip",
decision: "allow",
gate: activationBeforeSender,
gates,
});
}
}
const sender =
state.conversationKind === "direct"
? senderGateForDirect({ state, policy })
: senderGateForGroup({ state, policy });
const eventModeSender = applyEventAuthModeToSenderGate({ state, senderGate: sender });
gates.push(eventModeSender);
if (!eventModeSender.allowed) {
const admission =
eventModeSender.reasonCode === "dm_policy_pairing_required" ? "pairing-required" : "drop";
const decision =
eventModeSender.reasonCode === "dm_policy_pairing_required" ? "pairing" : "block";
return decisiveDecision({ admission, decision, gate: eventModeSender, gates });
}
const command = commandGate({ state, policy });
gates.push(command);
if (command.effect === "block-command") {
return decisiveDecision({ admission: "drop", decision: "block", gate: command, gates });
}
const event = eventGate({ state, senderGate: eventModeSender, commandGate: command });
gates.push(event);
if (!event.allowed) {
return decisiveDecision({ admission: "drop", decision: "block", gate: event, gates });
}
const activation =
activationBeforeSender ?? activationGate({ state, policy, commandGate: command });
if (!activationBeforeSender) {
gates.push(activation);
}
if (activation.effect === "skip") {
return decisiveDecision({ admission: "skip", decision: "allow", gate: activation, gates });
}
if (activation.effect === "observe") {
return decisiveDecision({ admission: "observe", decision: "allow", gate: activation, gates });
}
return decisiveDecision({ admission: "dispatch", decision: "allow", gate: activation, gates });
}

View File

@@ -0,0 +1,45 @@
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import type { ChannelId } from "../plugins/types.public.js";
import { readChannelIngressStoreAllowFromForDmPolicy } from "./runtime.js";
export async function resolveDmAllowAuditState(params: {
provider: ChannelId;
accountId: string;
allowFrom?: Array<string | number> | null;
dmPolicy?: string | null;
normalizeEntry?: (raw: string) => string;
readStore?: (provider: ChannelId, accountId: string) => Promise<string[]>;
}): Promise<{
configAllowFrom: string[];
hasWildcard: boolean;
allowCount: number;
isMultiUserDm: boolean;
}> {
const configAllowFrom = normalizeStringEntries(
Array.isArray(params.allowFrom) ? params.allowFrom : undefined,
);
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readChannelIngressStoreAllowFromForDmPolicy({
provider: params.provider,
accountId: params.accountId,
dmPolicy: params.dmPolicy,
readStore: params.readStore,
});
const normalizeEntry = params.normalizeEntry ?? ((value: string) => value);
const normalizedCfg = configAllowFrom
.filter((value) => value !== "*")
.map((value) => normalizeEntry(value))
.map((value) => value.trim())
.filter(Boolean);
const normalizedStore = storeAllowFrom
.map((value) => normalizeEntry(value))
.map((value) => value.trim())
.filter(Boolean);
const allowCount = new Set([...normalizedCfg, ...normalizedStore]).size;
return {
configAllowFrom,
hasWildcard,
allowCount,
isMultiUserDm: hasWildcard || allowCount > 1,
};
}

View File

@@ -0,0 +1,31 @@
export { decideChannelIngress } from "./decision.js";
export { defineStableChannelIngressIdentity } from "./runtime-identity.js";
export {
channelIngressRoutes,
createChannelIngressResolver,
readChannelIngressStoreAllowFromForDmPolicy,
resolveChannelMessageIngress,
resolveStableChannelMessageIngress,
} from "./runtime.js";
export { resolveChannelIngressState } from "./state.js";
export type {
ChannelIngressAccessGroupMembershipResolver,
ChannelIngressCommandPresetInput,
ChannelIngressConfigInput,
ChannelIngressEventPresetInput,
ChannelIngressIdentityAlias,
ChannelIngressIdentityDescriptor,
ChannelIngressIdentityField,
ChannelIngressIdentitySubjectInput,
ChannelIngressRouteAccess,
ChannelIngressRouteDescriptor,
ChannelIngressResolver,
ChannelIngressResolverMessageParams,
ChannelMessageIngressCommandInput,
CreateChannelIngressResolverParams,
ResolvedChannelMessageIngress,
ResolveChannelMessageIngressParams,
ResolveStableChannelMessageIngressParams,
StableChannelIngressIdentityParams,
} from "./runtime-types.js";
export type * from "./types.js";

View File

@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import {
decideChannelIngress,
resolveChannelIngressState,
type ChannelIngressPolicyInput,
type ChannelIngressStateInput,
type InternalChannelIngressAdapter,
type InternalChannelIngressSubject,
} from "./index.js";
const subject = (value: string): InternalChannelIngressSubject => ({
identifiers: [{ opaqueId: "subject-1", kind: "stable-id", value }],
});
const adapter: InternalChannelIngressAdapter = {
normalizeEntries({ entries }) {
return {
matchable: entries.map((entry, index) => ({
opaqueEntryId: `entry-${index + 1}`,
kind: "stable-id",
value: entry,
dangerous: entry.startsWith("display:"),
})),
invalid: [],
disabled: [],
};
},
matchSubject({ subject, entries }) {
const values = new Set(subject.identifiers.map((identifier) => identifier.value));
const matchedEntryIds = entries
.filter((entry) => entry.value === "*" || values.has(entry.value))
.map((entry) => entry.opaqueEntryId);
return { matched: matchedEntryIds.length > 0, matchedEntryIds };
},
};
const lowerCaseAdapter: InternalChannelIngressAdapter = {
normalizeEntries({ entries }) {
return {
matchable: entries.map((entry, index) => ({
opaqueEntryId: `entry-${index + 1}`,
kind: "stable-id",
value: entry.toLowerCase(),
})),
invalid: [],
disabled: [],
};
},
matchSubject({ subject, entries }) {
const values = new Set(subject.identifiers.map((identifier) => identifier.value.toLowerCase()));
const matchedEntryIds = entries
.filter((entry) => entry.kind === "stable-id" && values.has(entry.value))
.map((entry) => entry.opaqueEntryId);
return { matched: matchedEntryIds.length > 0, matchedEntryIds };
},
};
function baseInput(overrides: Partial<ChannelIngressStateInput> = {}): ChannelIngressStateInput {
return {
channelId: "test",
accountId: "default",
subject: subject("sender-1"),
conversation: { kind: "direct", id: "dm-1" },
adapter,
event: { kind: "message", authMode: "inbound", mayPair: true },
allowlists: {},
...overrides,
};
}
const policy: ChannelIngressPolicyInput = {
dmPolicy: "pairing",
groupPolicy: "allowlist",
};
describe("channel message access ingress", () => {
it.each([
{
name: "keeps pairing-store entries DM-policy scoped",
input: baseInput({
subject: subject("paired-sender"),
allowlists: { pairingStore: ["paired-sender"] },
}),
policy: { ...policy, dmPolicy: "open" as const },
expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" },
secondPolicy: { ...policy, dmPolicy: "pairing" as const },
secondExpected: { admission: "dispatch", decision: "allow" },
},
{
name: "requires explicit group fallback to DM allowlists",
input: baseInput({
conversation: { kind: "group", id: "room-1" },
allowlists: { dm: ["sender-1"] },
}),
policy,
expected: { admission: "drop", reasonCode: "group_policy_empty_allowlist" },
secondPolicy: { ...policy, groupAllowFromFallbackToAllowFrom: true },
secondExpected: { admission: "dispatch", decision: "allow" },
},
{
name: "requires explicit dangerous identifier matching",
input: baseInput({
subject: subject("display:sender-1"),
allowlists: { dm: ["display:sender-1"] },
}),
policy: { ...policy, dmPolicy: "allowlist" as const },
expected: { admission: "drop", reasonCode: "dm_policy_not_allowlisted" },
secondPolicy: {
...policy,
dmPolicy: "allowlist" as const,
mutableIdentifierMatching: "enabled" as const,
},
secondExpected: { admission: "dispatch", decision: "allow" },
},
])("$name", async ({ input, policy, expected, secondPolicy, secondExpected }) => {
const state = await resolveChannelIngressState(input);
expect(decideChannelIngress(state, policy)).toMatchObject(expected);
expect(decideChannelIngress(state, secondPolicy)).toMatchObject(secondExpected);
});
it("applies route sender allowlists without retaining raw sender values", async () => {
const rawSender = "route-sender@example.test";
const state = await resolveChannelIngressState(
baseInput({
subject: subject(rawSender),
conversation: { kind: "group", id: "room-1" },
routeFacts: [
{
id: "space-1",
kind: "route",
gate: "matched",
effect: "allow",
precedence: 0,
senderPolicy: "replace",
senderAllowFrom: [rawSender],
},
],
allowlists: { group: ["group-sender"] },
}),
);
const decision = decideChannelIngress(state, policy);
expect(state.routeFacts[0]?.senderAllowlist).toMatchObject({
hasConfiguredEntries: true,
match: { matched: true },
});
expect(decision).toMatchObject({ admission: "dispatch", decision: "allow" });
expect(JSON.stringify(state)).not.toContain(rawSender);
expect(JSON.stringify(decision)).not.toContain(rawSender);
});
it("blocks matched routes with deny-when-empty sender policy", async () => {
const state = await resolveChannelIngressState(
baseInput({
routeFacts: [
{
id: "space-1",
kind: "route",
gate: "matched",
effect: "allow",
precedence: 0,
senderPolicy: "deny-when-empty",
senderAllowFrom: [],
},
],
allowlists: { dm: ["sender-1"] },
}),
);
expect(decideChannelIngress(state, policy)).toMatchObject({
admission: "drop",
reasonCode: "route_sender_empty",
});
expect(state.routeFacts[0]).not.toHaveProperty("senderAllowFrom");
});
it.each([
{
name: "allows origin-subject events for the same normalized actor",
adapter,
current: "sender-1",
origin: "sender-1",
matched: true,
expected: { admission: "dispatch", decision: "allow" },
},
{
name: "does not authorize by default opaque identifier slots",
adapter,
current: "sender-1",
origin: "different-sender",
matched: false,
expected: { admission: "drop", decision: "block", reasonCode: "origin_subject_not_matched" },
},
{
name: "uses adapter-normalized identity values",
adapter: lowerCaseAdapter,
current: "Sender-1",
origin: "sender-1",
matched: true,
expected: { admission: "dispatch", decision: "allow" },
},
])("$name", async (entry) => {
const state = await resolveChannelIngressState(
baseInput({
adapter: entry.adapter,
subject: subject(entry.current),
event: {
kind: "reaction",
authMode: "origin-subject",
mayPair: false,
originSubject: subject(entry.origin),
},
}),
);
const decision = decideChannelIngress(state, policy);
expect(state.event.originSubjectMatched).toBe(entry.matched);
expect(decision).toMatchObject(entry.expected);
if (entry.matched) {
expect(
decision.graph.gates.find((gate) => gate.phase === "sender" && gate.kind === "dmSender"),
).toMatchObject({
effect: "ignore",
reasonCode: "sender_not_required",
});
}
});
});

View File

@@ -0,0 +1,95 @@
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { parseAccessGroupAllowFromEntry } from "../allow-from.js";
import type { ChannelIngressAdapter, ResolveChannelMessageIngressParams } from "./runtime-types.js";
import type { AccessGroupMembershipFact, ChannelIngressChannelId } from "./types.js";
function uniqueValues<T extends string | number>(values: readonly T[]): T[] {
return Array.from(new Set(values));
}
function accessGroupNames(entries: readonly (string | number)[]): string[] {
return Array.from(
new Set(
entries
.map((entry) => parseAccessGroupAllowFromEntry(String(entry)))
.filter((entry): entry is string => entry != null),
),
);
}
export function allReferencedAccessGroupNames(
entries: Array<readonly (string | number)[]>,
): string[] {
return Array.from(new Set(entries.flatMap((entryGroup) => accessGroupNames(entryGroup))));
}
export async function normalizeEffectiveEntries(params: {
adapter: ChannelIngressAdapter;
accountId: string;
entries: readonly (string | number)[];
context: "dm" | "group" | "route" | "command";
}): Promise<string[]> {
const rawEntries = normalizeStringEntries(params.entries);
const accessGroupEntries = rawEntries.filter(
(entry) => parseAccessGroupAllowFromEntry(entry) != null,
);
const directEntries = rawEntries.filter((entry) => parseAccessGroupAllowFromEntry(entry) == null);
if (directEntries.length === 0) {
return accessGroupEntries;
}
const normalized = await params.adapter.normalizeEntries({
entries: directEntries,
context: params.context,
accountId: params.accountId,
});
return uniqueValues([...accessGroupEntries, ...normalized.matchable.map((entry) => entry.value)]);
}
export async function resolveRuntimeAccessGroupMembershipFacts(params: {
input: ResolveChannelMessageIngressParams;
channelId: ChannelIngressChannelId;
names: readonly string[];
}): Promise<AccessGroupMembershipFact[]> {
if (!params.input.resolveAccessGroupMembership || params.names.length === 0) {
return [];
}
const facts: AccessGroupMembershipFact[] = [];
for (const name of params.names) {
const group = params.input.accessGroups?.[name];
if (!group || group.type === "message.senders") {
continue;
}
try {
const matched = await params.input.resolveAccessGroupMembership({
name,
group,
channelId: params.channelId,
accountId: params.input.accountId,
subject: params.input.subject,
});
facts.push(
matched
? {
kind: "matched",
groupName: name,
source: "dynamic",
matchedEntryIds: [`access-group:${name}`],
}
: {
kind: "not-matched",
groupName: name,
source: "dynamic",
},
);
} catch {
facts.push({
kind: "failed",
groupName: name,
source: "dynamic",
reasonCode: "access_group_failed",
diagnosticId: `access-group:${name}`,
});
}
}
return facts;
}

View File

@@ -0,0 +1,180 @@
import type {
ChannelIngressAdapter,
ChannelIngressAdapterEntry,
ChannelIngressIdentityDescriptor,
ChannelIngressIdentityField,
ChannelIngressIdentitySubjectInput,
ChannelIngressSubject,
StableChannelIngressIdentityParams,
} from "./runtime-types.js";
import type { InternalMatchMaterial } from "./types.js";
type ResolvedIdentityField = Required<Pick<ChannelIngressIdentityField, "key" | "kind">> &
Omit<ChannelIngressIdentityField, "key" | "kind">;
/** Build an identity descriptor for channels with one stable id and optional aliases. */
export function defineStableChannelIngressIdentity(
params: StableChannelIngressIdentityParams = {},
): ChannelIngressIdentityDescriptor {
const { entryIdPrefix, resolveEntryId, aliases, isWildcardEntry, matchEntry, ...primary } =
params;
return {
primary,
aliases,
isWildcardEntry,
matchEntry,
resolveEntryId:
resolveEntryId ??
(entryIdPrefix ? ({ entryIndex }) => `${entryIdPrefix}-${entryIndex + 1}` : undefined),
};
}
function defaultNormalize(value: string): string {
return value;
}
function normalizeFieldValue(
field: ResolvedIdentityField,
value: string,
mode: "entry" | "subject",
): string | null {
const normalize =
mode === "entry"
? (field.normalizeEntry ?? field.normalize ?? defaultNormalize)
: (field.normalizeSubject ?? field.normalize ?? defaultNormalize);
const normalized = normalize(value);
return normalized == null ? null : normalized.trim() || null;
}
function fieldDangerous(field: ResolvedIdentityField, value: string): boolean | undefined {
return typeof field.dangerous === "function" ? field.dangerous(value) : field.dangerous;
}
function identityFields(identity: ChannelIngressIdentityDescriptor): ResolvedIdentityField[] {
const fields: ResolvedIdentityField[] = [
{
...identity.primary,
key: identity.primary.key ?? "stableId",
kind: identity.primary.kind ?? "stable-id",
},
];
for (const alias of identity.aliases ?? []) {
fields.push({
...alias,
kind: alias.kind ?? (`plugin:${alias.key}` as const),
});
}
return fields;
}
function identityMatchKey(entry: Pick<ChannelIngressAdapterEntry, "kind" | "value">): string {
return `${entry.kind}:${entry.value}`;
}
function adapterEntry(params: {
identity: ChannelIngressIdentityDescriptor;
field: ResolvedIdentityField;
fieldIndex: number;
entry: string;
entryIndex: number;
value: string;
fallbackSuffix?: string;
}): ChannelIngressAdapterEntry {
return {
opaqueEntryId:
params.identity.resolveEntryId?.({
entry: params.entry,
entryIndex: params.entryIndex,
fieldKey: params.field.key,
fieldIndex: params.fieldIndex,
}) ?? `entry-${params.entryIndex + 1}:${params.fallbackSuffix ?? params.field.key}`,
kind: params.field.kind,
value: params.value,
dangerous: fieldDangerous(params.field, params.entry),
sensitivity: params.field.sensitivity,
};
}
export function createIdentityAdapter(
identity: ChannelIngressIdentityDescriptor,
): ChannelIngressAdapter {
const fields = identityFields(identity);
const isWildcardEntry = identity.isWildcardEntry ?? ((value: string) => value === "*");
return {
normalizeEntries({ entries }) {
const matchable = entries.flatMap((entry, entryIndex) => {
if (isWildcardEntry(entry)) {
return [
adapterEntry({
identity,
field: fields[0],
fieldIndex: 0,
entry,
entryIndex,
value: "*",
fallbackSuffix: "wildcard",
}),
];
}
return fields.flatMap((field, fieldIndex) => {
const value = normalizeFieldValue(field, entry, "entry");
if (!value) {
return [];
}
return [adapterEntry({ identity, field, fieldIndex, entry, entryIndex, value })];
});
});
return {
matchable,
invalid: [],
disabled: [],
};
},
matchSubject({ subject, entries, context }) {
const subjectKeys = new Set(
subject.identifiers.flatMap((identifier) => {
const field = fields.find((candidate) => candidate.kind === identifier.kind);
if (!field) {
return [];
}
const value = normalizeFieldValue(field, identifier.value, "subject");
return value ? [identityMatchKey({ kind: identifier.kind, value })] : [];
}),
);
const matchedEntryIds = entries
.filter((entry) => {
const fallback = entry.value === "*" || subjectKeys.has(identityMatchKey(entry));
return identity.matchEntry?.({ subject, entry, context }) ?? fallback;
})
.map((entry) => entry.opaqueEntryId);
return {
matched: matchedEntryIds.length > 0,
matchedEntryIds,
};
},
};
}
export function createIdentitySubject(
identity: ChannelIngressIdentityDescriptor,
input: ChannelIngressIdentitySubjectInput,
): ChannelIngressSubject {
const fields = identityFields(identity);
const identifiers: InternalMatchMaterial[] = fields.flatMap((field, index) => {
const rawValue = index === 0 ? input.stableId : input.aliases?.[field.key];
if (rawValue == null) {
return [];
}
const value = String(rawValue);
return [
{
opaqueId: field.key,
kind: field.kind,
value,
dangerous: fieldDangerous(field, value),
sensitivity: field.sensitivity,
},
];
});
return { identifiers };
}

View File

@@ -0,0 +1,368 @@
import type { AccessGroupConfig } from "../../config/types.access-groups.js";
import type {
AccessGroupMembershipFact,
AccessGraphGate,
ChannelIngressChannelId,
ChannelIngressDecision,
ChannelIngressEventInput,
ChannelIngressIdentifierKind,
ChannelIngressPolicyInput,
ChannelIngressState,
ChannelIngressStateInput,
IngressReasonCode,
InternalChannelIngressAdapter,
InternalChannelIngressSubject,
InternalMatchMaterial,
InternalNormalizedEntry,
RouteGateFacts,
} from "./types.js";
/** Normalized identifier material used to match an inbound sender against allowlist entries. */
export type ChannelIngressSubjectIdentifier = InternalMatchMaterial;
/** Redacted subject identity assembled from a stable id plus optional platform aliases. */
export type ChannelIngressSubject = InternalChannelIngressSubject;
/** Normalized allowlist entry material produced by a channel identity adapter. */
export type ChannelIngressAdapterEntry = InternalNormalizedEntry;
/** Adapter used by the ingress resolver to normalize entries and match subjects. */
export type ChannelIngressAdapter = InternalChannelIngressAdapter;
/** Describes one identity field used for stable ids or platform-specific aliases. */
export type ChannelIngressIdentityField = {
/** Unique field key used in subject alias maps and diagnostics. */
key?: string;
/** Redacted identifier kind written into the access graph. */
kind?: ChannelIngressIdentifierKind;
/** Shared normalizer used for both entries and subjects when no side-specific normalizer exists. */
normalize?: (value: string) => string | null | undefined;
/** Normalizes configured allowlist entries for this identity field. */
normalizeEntry?: (value: string) => string | null | undefined;
/** Normalizes inbound subject values for this identity field. */
normalizeSubject?: (value: string) => string | null | undefined;
/** Marks identifiers as dangerous in diagnostics, for example mutable display names. */
dangerous?: boolean | ((value: string) => boolean | undefined);
/** Redaction hint for diagnostics and access graph consumers. */
sensitivity?: "normal" | "pii";
};
/** Named alias field such as email, phone, UUID, room id, or platform user id. */
export type ChannelIngressIdentityAlias = ChannelIngressIdentityField & {
key: string;
};
/** Identity contract for a channel resolver. Plugins provide platform normalization here. */
export type ChannelIngressIdentityDescriptor = {
/** Primary stable identity field. Prefer immutable sender ids when the platform has one. */
primary: ChannelIngressIdentityField;
/** Additional identifiers that can match legacy or platform-specific allowlist entries. */
aliases?: readonly ChannelIngressIdentityAlias[];
/** Returns true when a raw allowlist entry should authorize every sender. */
isWildcardEntry?: (value: string) => boolean;
/** Optional custom match hook for platform-specific identity equivalence. */
matchEntry?: (params: {
subject: ChannelIngressSubject;
entry: ChannelIngressAdapterEntry;
context: "dm" | "group" | "route" | "command";
}) => boolean | undefined;
/** Generates stable redacted entry ids for diagnostics. */
resolveEntryId?: (params: {
entry: string;
entryIndex: number;
fieldKey: string;
fieldIndex: number;
}) => string;
};
/** Convenience input for defining a stable identity descriptor with optional aliases. */
export type StableChannelIngressIdentityParams = ChannelIngressIdentityField &
Pick<ChannelIngressIdentityDescriptor, "aliases" | "isWildcardEntry" | "matchEntry"> & {
/** Prefix used for generated entry ids when `resolveEntryId` is omitted. */
entryIdPrefix?: string;
/** Custom entry-id generator used in redacted diagnostics. */
resolveEntryId?: ChannelIngressIdentityDescriptor["resolveEntryId"];
};
/** Raw sender identity passed by a plugin for one inbound event. */
export type ChannelIngressIdentitySubjectInput = {
/** Stable sender id appended to effective allowlists when access groups matched. */
stableId?: string | number | null;
/** Optional identity aliases keyed by `ChannelIngressIdentityAlias.key`. */
aliases?: Record<string, string | number | null | undefined>;
};
/** Minimal config subset consumed by the ingress resolver. */
export type ChannelIngressConfigInput = {
/** Static or dynamic access group definitions referenced by allowlist entries. */
accessGroups?: ChannelIngressStateInput["accessGroups"];
/** Command config used for access-group command behavior. */
commands?: { useAccessGroups?: boolean } | null;
} | null;
/** Command gate input for control-command authorization. */
export type ChannelMessageIngressCommandInput = NonNullable<
ChannelIngressPolicyInput["command"]
> & {
/** Explicit command-owner allowlist; defaults to effective DM allowlist. */
commandOwnerAllowFrom?: Array<string | number> | null;
/** Controls whether group command owners inherit configured DM owners. */
groupOwnerAllowFrom?: "configured" | "none";
/** Allows direct-message command checks to reuse effective group allowlists. */
directGroupAllowFrom?: "effective" | "none";
/** Group command allowFrom fallback, separate from normal group sender policy. */
commandGroupAllowFromFallbackToAllowFrom?: boolean;
};
/** Preset form for command gates accepted by `createChannelIngressResolver`. */
export type ChannelIngressCommandPresetInput = Omit<
Partial<ChannelMessageIngressCommandInput>,
"useAccessGroups"
> & {
/** Set false to omit the command gate entirely. */
requested?: boolean;
/** Overrides `cfg.commands.useAccessGroups` for this command decision. */
useAccessGroups?: boolean | null;
/** Config subset used to derive command access-group behavior. */
cfg?: ChannelIngressConfigInput;
};
/** Preset form for event gates accepted by `createChannelIngressResolver`. */
export type ChannelIngressEventPresetInput = Partial<ChannelIngressEventInput> & {
/** Convenience flag used to derive pairing defaults for group events. */
isGroup?: boolean;
};
/** Optional route gate, such as a room, thread, topic, guild, or group route. */
export type ChannelIngressRouteDescriptor = {
/** Stable route id used in diagnostics. */
id: string;
/** Route kind for diagnostics and graph consumers. */
kind?: RouteGateFacts["kind"];
/** Whether this route policy is configured. */
configured?: boolean;
/** Whether the inbound event matched this route. */
matched?: boolean;
/** Whether this route admits the inbound event. */
allowed?: boolean;
/** Whether to include this route descriptor in the graph. */
enabled?: boolean;
/** Ordering hint when multiple route descriptors are supplied. */
precedence?: number;
/** How route sender allowlists combine with effective channel allowlists. */
senderPolicy?: RouteGateFacts["senderPolicy"];
/** Route-specific sender allowlist entries. */
senderAllowFrom?: Array<string | number> | null;
/** Indicates whether route sender entries came from effective DM or group policy. */
senderAllowFromSource?: RouteGateFacts["senderAllowFromSource"];
/** Optional redacted match id for the route. */
matchId?: string;
/** Reason used when this route blocks the event. */
blockReason?: string;
};
/** Dynamic access-group resolver invoked for groups that need platform lookups. */
export type ChannelIngressAccessGroupMembershipResolver = (params: {
name: string;
group: AccessGroupConfig;
channelId: ChannelIngressChannelId;
accountId: string;
subject: ChannelIngressIdentitySubjectInput;
}) => boolean | Promise<boolean>;
/** Complete input for resolving one inbound channel message or event. */
export type ResolveChannelMessageIngressParams = {
/** Channel id used for config, diagnostics, access groups, and pairing-store reads. */
channelId: ChannelIngressChannelId;
/** Account id scoped to this channel instance. */
accountId: string;
/** Identity descriptor that normalizes sender and allowlist material. */
identity: ChannelIngressIdentityDescriptor;
/** Inbound sender identity for this event. */
subject: ChannelIngressIdentitySubjectInput;
/** Conversation classification and id. */
conversation: ChannelIngressStateInput["conversation"];
/** Event auth mode and pairing/origin-subject facts. */
event: ChannelIngressEventInput;
/** Sender, command, event, route, and activation policy. */
policy: ChannelIngressPolicyInput;
/** Raw direct-message allowlist entries. */
allowFrom?: Array<string | number> | null;
/** Raw group sender allowlist entries. */
groupAllowFrom?: Array<string | number> | null;
/** Route descriptors used to build route gates. */
route?: ChannelIngressRouteDescriptor | readonly ChannelIngressRouteDescriptor[];
/** Prebuilt route facts for lower-level callers. */
routeFacts?: RouteGateFacts[];
/** Access group config referenced by allowlist entries. */
accessGroups?: ChannelIngressStateInput["accessGroups"];
/** Precomputed access-group memberships for this subject. */
accessGroupMembership?: readonly AccessGroupMembershipFact[];
/** Resolver for dynamic access groups. */
resolveAccessGroupMembership?: ChannelIngressAccessGroupMembershipResolver;
/** Concrete sender entry appended to effective allowlists when an access group matched. */
accessGroupMatchedAllowFromEntry?: string | number | null;
/** Records whether a provider-specific missing-config fallback was applied. */
providerMissingFallbackApplied?: boolean;
/** Mention or activation facts for activation gates. */
mentionFacts?: ChannelIngressStateInput["mentionFacts"];
/** Optional pairing-store reader for direct-message allowlist material. */
readStoreAllowFrom?: (params: {
channelId: ChannelIngressChannelId;
accountId: string;
dmPolicy: ChannelIngressPolicyInput["dmPolicy"];
}) => Promise<readonly (string | number)[] | null | undefined>;
/** Reads the default pairing store when no explicit reader is supplied. */
useDefaultPairingStore?: boolean;
/** Command gate input; omit when no command policy is requested. */
command?: ChannelMessageIngressCommandInput;
};
/** Shared resolver defaults for repeated events from the same channel account. */
export type CreateChannelIngressResolverParams = Pick<
ResolveChannelMessageIngressParams,
| "channelId"
| "accountId"
| "identity"
| "accessGroups"
| "accessGroupMembership"
| "resolveAccessGroupMembership"
| "accessGroupMatchedAllowFromEntry"
| "readStoreAllowFrom"
| "useDefaultPairingStore"
> & {
/** Config subset used for access groups and command behavior. */
cfg?: ChannelIngressConfigInput;
/** Global override for access-group expansion in this resolver. */
useAccessGroups?: boolean | null;
/** Default DM policy for message calls that omit it. */
defaultDmPolicy?: ChannelIngressPolicyInput["dmPolicy"];
/** Default group policy for message calls that omit it. */
defaultGroupPolicy?: ChannelIngressPolicyInput["groupPolicy"];
/** Default group allowlist fallback behavior. */
groupAllowFromFallbackToAllowFrom?: boolean;
/** Mutable identifier matching policy for this resolver. */
mutableIdentifierMatching?: ChannelIngressPolicyInput["mutableIdentifierMatching"];
};
/** Per-message input for a resolver created by `createChannelIngressResolver`. */
export type ChannelIngressResolverMessageParams = Omit<
ResolveChannelMessageIngressParams,
| "channelId"
| "accountId"
| "identity"
| "accessGroups"
| "resolveAccessGroupMembership"
| "accessGroupMatchedAllowFromEntry"
| "readStoreAllowFrom"
| "useDefaultPairingStore"
| "event"
| "policy"
| "command"
> & {
/** Event facts or presets; defaults to a normal inbound message event. */
event?: ChannelIngressEventInput | ChannelIngressEventPresetInput;
/** DM policy override for this event. */
dmPolicy?: ChannelIngressPolicyInput["dmPolicy"];
/** Group policy override for this event. */
groupPolicy?: ChannelIngressPolicyInput["groupPolicy"];
/** Additional policy fields merged with resolver defaults. */
policy?: Partial<Omit<ChannelIngressPolicyInput, "dmPolicy" | "groupPolicy">>;
/** Command gate input, preset, or false to suppress command checks. */
command?: ChannelMessageIngressCommandInput | ChannelIngressCommandPresetInput | false;
};
/** Reusable high-level ingress resolver for message, command, and event surfaces. */
export type ChannelIngressResolver = {
/** Resolve a normal inbound message with sender, route, command, event, and activation gates. */
message(params: ChannelIngressResolverMessageParams): Promise<ResolvedChannelMessageIngress>;
/** Resolve a command-oriented event with command auth defaults enabled. */
command(params: ChannelIngressResolverMessageParams): Promise<ResolvedChannelMessageIngress>;
/** Resolve a non-message event with event-gate defaults enabled. */
event(params: ChannelIngressResolverMessageParams): Promise<ResolvedChannelMessageIngress>;
};
/** One-shot helper input using a simple stable identity descriptor. */
export type ResolveStableChannelMessageIngressParams = Omit<
CreateChannelIngressResolverParams,
"identity"
> &
ChannelIngressResolverMessageParams & { identity?: StableChannelIngressIdentityParams };
/** Sender/conversation projection consumed by channel handlers. */
export type ChannelIngressSenderAccess = {
/** True when the sender gate admits the event. */
allowed: boolean;
/** Final ingress decision after all gates, not just the sender gate. */
decision: ChannelIngressDecision["decision"];
/** Sender gate reason when present, otherwise decisive ingress reason. */
reasonCode: IngressReasonCode;
/** Sender gate from the access graph, when one ran. */
gate?: AccessGraphGate;
/** Effective DM allowlist entries after store and access-group processing. */
effectiveAllowFrom: string[];
/** Effective group allowlist entries after fallback and access-group processing. */
effectiveGroupAllowFrom: string[];
/** Whether provider-specific fallback behavior was applied. */
providerMissingFallbackApplied: boolean;
};
/** Command projection consumed by channel command/control handlers. */
export type ChannelIngressCommandAccess = {
/** True when a command gate was requested for this event. */
requested: boolean;
/** True when the command gate authorizes this sender. */
authorized: boolean;
/** True when an unauthorized control command should be blocked. */
shouldBlockControlCommand: boolean;
/** Command gate reason when present, otherwise decisive ingress reason. */
reasonCode: IngressReasonCode;
/** Command gate from the access graph, when one ran. */
gate?: AccessGraphGate;
};
/** Route projection consumed by room/thread/topic handlers. */
export type ChannelIngressRouteAccess = {
/** True when all configured route gates admit the event. */
allowed: boolean;
/** Route gate reason when a route gate decided. */
reasonCode?: IngressReasonCode;
/** Optional route-specific reason text. */
reason?: string;
/** Route gate from the access graph, when one ran. */
gate?: AccessGraphGate;
};
/** Activation/mention projection consumed by group handlers. */
export type ChannelIngressActivationAccess = {
/** True when an activation gate ran. */
ran: boolean;
/** True when activation admits the event. */
allowed: boolean;
/** True when the event should be skipped instead of dispatched. */
shouldSkip: boolean;
/** Activation gate reason when present, otherwise decisive ingress reason. */
reasonCode: IngressReasonCode;
/** Effective mention match after command bypass and activation policy. */
effectiveWasMentioned?: boolean;
/** True when mention gating was bypassed by policy or command facts. */
shouldBypassMention?: boolean;
/** Activation gate from the access graph, when one ran. */
gate?: AccessGraphGate;
};
/** Full ingress result returned by runtime resolvers. */
export type ResolvedChannelMessageIngress = {
/** Redacted normalized state used as input to the decision engine. */
state: ChannelIngressState;
/** Ordered access graph plus final admission decision. */
ingress: ChannelIngressDecision;
/** Sender/conversation projection. */
senderAccess: ChannelIngressSenderAccess;
/** Route projection. */
routeAccess: ChannelIngressRouteAccess;
/** Command projection. */
commandAccess: ChannelIngressCommandAccess;
/** Activation/mention projection. */
activationAccess: ChannelIngressActivationAccess;
};

View File

@@ -0,0 +1,722 @@
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
import type { PairingChannel } from "../../pairing/pairing-store.types.js";
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../allow-from.js";
import { decideChannelIngress } from "./decision.js";
import {
allReferencedAccessGroupNames,
normalizeEffectiveEntries,
resolveRuntimeAccessGroupMembershipFacts,
} from "./runtime-access-groups.js";
import {
createIdentityAdapter,
createIdentitySubject,
defineStableChannelIngressIdentity,
} from "./runtime-identity.js";
import type {
ChannelMessageIngressCommandInput,
ChannelIngressCommandPresetInput,
ChannelIngressEventPresetInput,
ChannelIngressActivationAccess,
ChannelIngressCommandAccess,
ChannelIngressRouteAccess,
ChannelIngressRouteDescriptor,
ChannelIngressResolver,
ChannelIngressResolverMessageParams,
ChannelIngressSenderAccess,
CreateChannelIngressResolverParams,
ResolveChannelMessageIngressParams,
ResolveStableChannelMessageIngressParams,
ResolvedChannelMessageIngress,
} from "./runtime-types.js";
import { resolveChannelIngressState } from "./state.js";
import type {
AccessGraphGate,
ChannelIngressChannelId,
ChannelIngressEventInput,
ChannelIngressPolicyInput,
ChannelIngressStateInput,
RedactedIngressMatch,
ResolvedIngressAllowlist,
RouteGateFacts,
RouteSenderPolicy,
} from "./types.js";
type RouteFactDefaults = {
id: string;
kind?: RouteGateFacts["kind"];
precedence?: number;
senderPolicy?: RouteSenderPolicy;
senderAllowFrom?: Array<string | number>;
senderAllowFromSource?: RouteGateFacts["senderAllowFromSource"];
match?: RedactedIngressMatch;
};
function shouldReadStore(params: {
conversationKind: ChannelIngressStateInput["conversation"]["kind"];
dmPolicy: ChannelIngressPolicyInput["dmPolicy"];
}): boolean {
return (
params.conversationKind === "direct" &&
params.dmPolicy !== "allowlist" &&
params.dmPolicy !== "open"
);
}
/**
* Merge configured direct, group, and pairing-store allowlists into the
* effective lists consumed by sender and context-visibility checks.
*/
export function resolveChannelIngressEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | null;
dmPolicy?: string | null;
groupAllowFromFallbackToAllowFrom?: boolean | null;
}): {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined;
const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined;
const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined;
const effectiveAllowFrom = normalizeStringEntries(
mergeDmAllowFromSources({
allowFrom,
storeAllowFrom,
dmPolicy: params.dmPolicy ?? undefined,
}),
);
const effectiveGroupAllowFrom = normalizeStringEntries(
resolveGroupAllowFromSources({
allowFrom,
groupAllowFrom,
fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined,
}),
);
return { effectiveAllowFrom, effectiveGroupAllowFrom };
}
/**
* Read pairing-store allowlist entries when a direct-message policy permits
* store fallback.
*/
export async function readChannelIngressStoreAllowFromForDmPolicy(params: {
provider: PairingChannel;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
readStore?: (provider: PairingChannel, accountId: string) => Promise<string[]>;
}): Promise<string[]> {
if (
params.shouldRead === false ||
params.dmPolicy === "allowlist" ||
params.dmPolicy === "open"
) {
return [];
}
const readStore =
params.readStore ??
((provider: PairingChannel, accountId: string) =>
readChannelAllowFromStore(provider, process.env, accountId));
return await readStore(params.provider, params.accountId).catch(() => []);
}
async function readStoreAllowFrom(
params: ResolveChannelMessageIngressParams & { channelId: ChannelIngressChannelId },
): Promise<Array<string | number>> {
if (
!shouldReadStore({
conversationKind: params.conversation.kind,
dmPolicy: params.policy.dmPolicy,
})
) {
return [];
}
const entries = params.readStoreAllowFrom
? await params
.readStoreAllowFrom({
channelId: params.channelId,
accountId: params.accountId,
dmPolicy: params.policy.dmPolicy,
})
.catch(() => [])
: params.useDefaultPairingStore
? await readChannelIngressStoreAllowFromForDmPolicy({
provider: params.channelId as PairingChannel,
accountId: params.accountId,
dmPolicy: params.policy.dmPolicy,
})
: [];
return [...(entries ?? [])];
}
function commandRequested(policy: ChannelIngressPolicyInput): boolean {
return policy.command != null;
}
function normalizeChannelId(id: string): ChannelIngressChannelId {
const trimmed = id.trim();
if (!trimmed) {
throw new Error("Channel ingress channel id must be non-empty.");
}
return trimmed;
}
function findIngressGate(params: {
ingress: ResolvedChannelMessageIngress["ingress"];
phase: AccessGraphGate["phase"];
kind: AccessGraphGate["kind"];
}): AccessGraphGate | undefined {
return params.ingress.graph.gates.find(
(gate) => gate.phase === params.phase && gate.kind === params.kind,
);
}
function findSenderGate(
ingress: ResolvedChannelMessageIngress["ingress"],
isGroup: boolean,
): AccessGraphGate | undefined {
return findIngressGate({
ingress,
phase: "sender",
kind: isGroup ? "groupSender" : "dmSender",
});
}
function useAccessGroupsFromConfig(params: {
useAccessGroups?: boolean | null;
cfg?: ChannelIngressCommandPresetInput["cfg"];
}): boolean {
return params.useAccessGroups ?? params.cfg?.commands?.useAccessGroups !== false;
}
function channelIngressCommand(
params: ChannelIngressCommandPresetInput = {},
): ChannelMessageIngressCommandInput | undefined {
if (params.requested === false) {
return undefined;
}
const { requested: _requested, cfg, ...command } = params;
return {
...command,
useAccessGroups: useAccessGroupsFromConfig({
useAccessGroups: params.useAccessGroups,
cfg,
}),
allowTextCommands: params.allowTextCommands ?? false,
hasControlCommand: params.hasControlCommand ?? true,
};
}
function channelIngressEvent(
params: ChannelIngressEventPresetInput = {},
): ChannelIngressEventInput {
const isGroup = params.isGroup ?? false;
return {
kind: params.kind ?? "message",
authMode: params.authMode ?? "inbound",
mayPair: params.mayPair ?? !isGroup,
...(params.originSubject ? { originSubject: params.originSubject } : {}),
};
}
function resolveCommandInput(params: {
command?: ChannelIngressResolverMessageParams["command"];
useAccessGroups?: boolean | null;
}): ChannelMessageIngressCommandInput | undefined {
if (params.command === false || params.command == null) {
return undefined;
}
return channelIngressCommand({
...params.command,
useAccessGroups: params.command.useAccessGroups ?? params.useAccessGroups,
});
}
function resolveResolverPolicy(params: {
base: CreateChannelIngressResolverParams;
input: ChannelIngressResolverMessageParams;
}): ChannelIngressPolicyInput {
return {
dmPolicy: params.input.dmPolicy ?? params.base.defaultDmPolicy ?? "pairing",
groupPolicy: params.input.groupPolicy ?? params.base.defaultGroupPolicy ?? "disabled",
groupAllowFromFallbackToAllowFrom:
params.input.policy?.groupAllowFromFallbackToAllowFrom ??
params.base.groupAllowFromFallbackToAllowFrom,
mutableIdentifierMatching:
params.input.policy?.mutableIdentifierMatching ?? params.base.mutableIdentifierMatching,
...(params.input.policy?.activation ? { activation: params.input.policy.activation } : {}),
};
}
/**
* Create a reusable ingress resolver for one channel account and identity
* descriptor.
*/
export function createChannelIngressResolver(
base: CreateChannelIngressResolverParams,
): ChannelIngressResolver {
const resolve = async (
input: ChannelIngressResolverMessageParams,
eventDefaults?: ChannelIngressEventPresetInput,
) => {
const isGroup = input.conversation.kind !== "direct";
const useAccessGroups = useAccessGroupsFromConfig({
useAccessGroups: base.useAccessGroups,
cfg: base.cfg,
});
return await resolveChannelMessageIngress({
channelId: base.channelId,
accountId: base.accountId,
identity: base.identity,
subject: input.subject,
conversation: input.conversation,
event: channelIngressEvent({
isGroup,
...eventDefaults,
...input.event,
}),
policy: resolveResolverPolicy({ base, input }),
allowFrom: input.allowFrom,
groupAllowFrom: input.groupAllowFrom,
route: input.route,
routeFacts: input.routeFacts,
accessGroups: base.accessGroups ?? base.cfg?.accessGroups,
accessGroupMembership: [
...(base.accessGroupMembership ?? []),
...(input.accessGroupMembership ?? []),
],
resolveAccessGroupMembership: base.resolveAccessGroupMembership,
accessGroupMatchedAllowFromEntry: base.accessGroupMatchedAllowFromEntry,
providerMissingFallbackApplied: input.providerMissingFallbackApplied,
mentionFacts: input.mentionFacts,
readStoreAllowFrom: base.readStoreAllowFrom,
useDefaultPairingStore: base.useDefaultPairingStore,
command: resolveCommandInput({
command: input.command,
useAccessGroups,
}),
});
};
return {
message: async (input) => await resolve(input),
command: async (input) =>
await resolve(input, {
authMode: "command",
mayPair: false,
}),
event: async (input) => await resolve(input, { mayPair: false }),
};
}
/**
* Resolve one inbound event using a simple stable subject identity descriptor.
*/
export async function resolveStableChannelMessageIngress(
params: ResolveStableChannelMessageIngressParams,
): Promise<ResolvedChannelMessageIngress> {
return await createChannelIngressResolver({
...params,
identity: defineStableChannelIngressIdentity(params.identity),
}).message(params);
}
function routeDescriptors(
route: ResolveChannelMessageIngressParams["route"],
): ChannelIngressRouteDescriptor[] {
if (!route) {
return [];
}
if (Array.isArray(route)) {
return [...route];
}
return [route as ChannelIngressRouteDescriptor];
}
/**
* Collect optional route descriptors while dropping false, null, and undefined
* entries.
*/
export function channelIngressRoutes(
...routes: Array<ChannelIngressRouteDescriptor | false | null | undefined>
): ChannelIngressRouteDescriptor[] {
return routes.filter((route): route is ChannelIngressRouteDescriptor => Boolean(route));
}
function routeDescriptorMatch(descriptor: ChannelIngressRouteDescriptor) {
const matched = descriptor.matched ?? descriptor.allowed ?? descriptor.enabled !== false;
return {
matched,
matchedEntryIds: matched && descriptor.matchId ? [descriptor.matchId] : [],
};
}
function routeFact(
params: RouteFactDefaults & Pick<RouteGateFacts, "gate" | "effect">,
): RouteGateFacts {
return {
id: params.id,
kind: params.kind ?? "route",
gate: params.gate,
effect: params.effect,
precedence: params.precedence ?? 0,
senderPolicy: params.senderPolicy ?? "inherit",
senderAllowFrom: params.senderAllowFrom,
senderAllowFromSource: params.senderAllowFromSource,
match: params.match,
};
}
function routeFactDefaults(descriptor: ChannelIngressRouteDescriptor) {
return {
id: descriptor.id,
...(descriptor.kind ? { kind: descriptor.kind } : {}),
...(descriptor.precedence !== undefined ? { precedence: descriptor.precedence } : {}),
...(descriptor.senderPolicy ? { senderPolicy: descriptor.senderPolicy } : {}),
...(descriptor.senderAllowFrom != null
? { senderAllowFrom: [...descriptor.senderAllowFrom] }
: {}),
...(descriptor.senderAllowFromSource
? { senderAllowFromSource: descriptor.senderAllowFromSource }
: {}),
match: routeDescriptorMatch(descriptor),
};
}
function routeFactsFromDescriptors(
route: ResolveChannelMessageIngressParams["route"],
): RouteGateFacts[] {
return routeDescriptors(route).flatMap((descriptor) => {
if (descriptor.configured === false) {
return [];
}
const defaults = routeFactDefaults(descriptor);
if (descriptor.enabled === false) {
return [routeFact({ ...defaults, gate: "disabled", effect: "block-dispatch" })];
}
if (descriptor.allowed !== undefined) {
return [
routeFact({
...defaults,
gate: descriptor.allowed ? "matched" : "not-matched",
effect: descriptor.allowed ? "allow" : "block-dispatch",
}),
];
}
if (
descriptor.senderPolicy !== "deny-when-empty" &&
descriptor.senderAllowFrom == null &&
descriptor.senderAllowFromSource == null
) {
return [];
}
return [
routeFact({
...defaults,
kind: descriptor.senderPolicy === "deny-when-empty" ? defaults.kind : "routeSender",
gate: "matched",
effect: "allow",
senderPolicy:
descriptor.senderPolicy === "deny-when-empty" ? "deny-when-empty" : defaults.senderPolicy,
}),
];
});
}
function routeDescriptorForGate(params: {
descriptors: readonly ChannelIngressRouteDescriptor[];
gate: AccessGraphGate;
}): ChannelIngressRouteDescriptor | undefined {
const senderSuffix = ":sender";
const baseGateId = params.gate.id.endsWith(senderSuffix)
? params.gate.id.slice(0, -senderSuffix.length)
: params.gate.id;
return params.descriptors.find(
(descriptor) => descriptor.id === params.gate.id || descriptor.id === baseGateId,
);
}
function projectRouteAccess(params: {
ingress: ResolvedChannelMessageIngress["ingress"];
route: ResolveChannelMessageIngressParams["route"];
}): ChannelIngressRouteAccess {
const descriptors = routeDescriptors(params.route);
const routeBlock = params.ingress.graph.gates.find(
(entry) => entry.phase === "route" && entry.effect === "block-dispatch",
);
if (routeBlock) {
const descriptor = routeDescriptorForGate({ descriptors, gate: routeBlock });
return {
allowed: routeBlock.allowed,
reasonCode: routeBlock.reasonCode,
...(descriptor?.blockReason ? { reason: descriptor.blockReason } : {}),
gate: routeBlock,
};
}
const routeSenderReplacement = descriptors.find(
(descriptor) => descriptor.senderPolicy === "replace" && descriptor.blockReason,
);
const senderBlock = params.ingress.graph.gates.find(
(entry) => entry.phase === "sender" && entry.effect === "block-dispatch",
);
if (routeSenderReplacement && senderBlock) {
return {
allowed: false,
reasonCode: senderBlock.reasonCode,
reason: routeSenderReplacement.blockReason,
gate: senderBlock,
};
}
const gate = params.ingress.graph.gates.find((entry) => entry.phase === "route");
if (gate) {
return {
allowed: gate.allowed,
reasonCode: gate.reasonCode,
gate,
};
}
return { allowed: true };
}
function projectSenderAccess(params: {
ingress: ResolvedChannelMessageIngress["ingress"];
isGroup: boolean;
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
providerMissingFallbackApplied?: boolean;
}): ChannelIngressSenderAccess {
const gate = findSenderGate(params.ingress, params.isGroup);
const reasonCode =
!gate &&
params.isGroup &&
params.ingress.reasonCode === "route_sender_empty" &&
params.effectiveGroupAllowFrom.length === 0
? "group_policy_empty_allowlist"
: (gate?.reasonCode ?? params.ingress.reasonCode);
const decision =
reasonCode === "dm_policy_pairing_required"
? "pairing"
: gate?.allowed === true
? "allow"
: "block";
return {
allowed: decision === "allow",
decision,
reasonCode,
...(gate ? { gate } : {}),
effectiveAllowFrom: params.effectiveAllowFrom,
effectiveGroupAllowFrom: params.effectiveGroupAllowFrom,
providerMissingFallbackApplied: params.providerMissingFallbackApplied ?? false,
};
}
function projectCommandAccess(params: {
ingress: ResolvedChannelMessageIngress["ingress"];
policy: ChannelIngressPolicyInput;
}): ChannelIngressCommandAccess {
const gate = findIngressGate({
ingress: params.ingress,
phase: "command",
kind: "command",
});
return {
requested: commandRequested(params.policy),
authorized: commandRequested(params.policy) ? gate?.allowed === true : false,
shouldBlockControlCommand: gate?.command?.shouldBlockControlCommand === true,
reasonCode: gate?.reasonCode ?? params.ingress.reasonCode,
...(gate ? { gate } : {}),
};
}
function projectActivationAccess(params: {
ingress: ResolvedChannelMessageIngress["ingress"];
}): ChannelIngressActivationAccess {
const gate = findIngressGate({
ingress: params.ingress,
phase: "activation",
kind: "mention",
});
return {
ran: gate != null,
allowed: gate?.allowed === true,
shouldSkip: gate?.activation?.shouldSkip === true,
reasonCode: gate?.reasonCode ?? params.ingress.reasonCode,
...(gate?.activation?.effectiveWasMentioned !== undefined
? { effectiveWasMentioned: gate.activation.effectiveWasMentioned }
: {}),
...(gate?.activation?.shouldBypassMention !== undefined
? { shouldBypassMention: gate.activation.shouldBypassMention }
: {}),
...(gate ? { gate } : {}),
};
}
function commandOwnerAllowFrom(params: {
command?: ChannelMessageIngressCommandInput;
isGroup: boolean;
configuredAllowFrom: Array<string | number>;
effectiveAllowFrom: string[];
}): Array<string | number> {
if (params.command?.commandOwnerAllowFrom != null) {
return params.command.commandOwnerAllowFrom;
}
if (!params.isGroup) {
return params.effectiveAllowFrom;
}
return params.command?.groupOwnerAllowFrom === "none" ? [] : params.configuredAllowFrom;
}
function commandGroupAllowFrom(params: {
command?: ChannelMessageIngressCommandInput;
isGroup: boolean;
effectiveCommandGroupAllowFrom: string[];
}): Array<string | number> {
if (params.isGroup) {
return params.effectiveCommandGroupAllowFrom;
}
return params.command?.directGroupAllowFrom === "effective"
? params.effectiveCommandGroupAllowFrom
: [];
}
function accessGroupMatchedEntry(params: ResolveChannelMessageIngressParams): string | null {
const entry = params.accessGroupMatchedAllowFromEntry ?? params.subject.stableId;
return entry == null ? null : String(entry);
}
function appendAccessGroupMatchedEntry(params: {
entries: string[];
allowlist: ResolvedIngressAllowlist;
matchedEntry: string | null;
}): string[] {
return params.matchedEntry && params.allowlist.accessGroups.matched.length > 0
? Array.from(new Set([...params.entries, params.matchedEntry]))
: params.entries;
}
/**
* Resolve sender, route, command, event, and activation gates for one inbound
* channel event.
*/
export async function resolveChannelMessageIngress(
params: ResolveChannelMessageIngressParams,
): Promise<ResolvedChannelMessageIngress> {
const channelId = normalizeChannelId(params.channelId);
const adapter = createIdentityAdapter(params.identity);
const subject = createIdentitySubject(params.identity, params.subject);
const routeFacts = [...routeFactsFromDescriptors(params.route), ...(params.routeFacts ?? [])];
const storeAllowFrom = await readStoreAllowFrom({ ...params, channelId });
const rawAllowFrom = normalizeStringEntries(params.allowFrom ?? []);
const rawStoreAllowFrom = normalizeStringEntries(storeAllowFrom);
const rawGroupAllowFrom = normalizeStringEntries(params.groupAllowFrom ?? []);
const normalizeEffective = (entries: readonly (string | number)[], context: "dm" | "group") =>
normalizeEffectiveEntries({ adapter, accountId: params.accountId, entries, context });
const [normalizedAllowFrom, normalizedStoreAllowFrom, normalizedGroupAllowFrom] =
await Promise.all([
normalizeEffective(rawAllowFrom, "dm"),
normalizeEffective(rawStoreAllowFrom, "dm"),
normalizeEffective(rawGroupAllowFrom, "group"),
]);
const referencedAccessGroups = allReferencedAccessGroupNames([
rawAllowFrom,
rawGroupAllowFrom,
rawStoreAllowFrom,
params.command?.commandOwnerAllowFrom ?? [],
...routeFacts.map((route) => route.senderAllowFrom ?? []),
]);
const runtimeAccessGroupMembership = await resolveRuntimeAccessGroupMembershipFacts({
input: params,
channelId,
names: referencedAccessGroups,
});
const accessGroupMembership = [
...runtimeAccessGroupMembership,
...(params.accessGroupMembership ?? []),
];
const baseEffective = resolveChannelIngressEffectiveAllowFromLists({
allowFrom: normalizedAllowFrom,
groupAllowFrom: normalizedGroupAllowFrom,
storeAllowFrom: normalizedStoreAllowFrom,
dmPolicy: params.policy.dmPolicy,
groupAllowFromFallbackToAllowFrom: params.policy.groupAllowFromFallbackToAllowFrom,
});
const rawEffective = resolveChannelIngressEffectiveAllowFromLists({
allowFrom: rawAllowFrom,
groupAllowFrom: rawGroupAllowFrom,
storeAllowFrom: rawStoreAllowFrom,
dmPolicy: params.policy.dmPolicy,
groupAllowFromFallbackToAllowFrom: params.policy.groupAllowFromFallbackToAllowFrom,
});
const rawCommandGroup = resolveChannelIngressEffectiveAllowFromLists({
allowFrom: rawAllowFrom,
groupAllowFrom: rawGroupAllowFrom,
dmPolicy: params.policy.dmPolicy,
groupAllowFromFallbackToAllowFrom:
params.command?.commandGroupAllowFromFallbackToAllowFrom ??
params.policy.groupAllowFromFallbackToAllowFrom,
});
const isGroup = params.conversation.kind !== "direct";
const policy: ChannelIngressPolicyInput = {
...params.policy,
...(params.command !== undefined ? { command: params.command } : {}),
};
const state = await resolveChannelIngressState({
channelId,
accountId: params.accountId,
subject,
conversation: params.conversation,
adapter,
accessGroups: params.accessGroups,
accessGroupMembership,
routeFacts,
mentionFacts: params.mentionFacts,
event: params.event,
allowlists: {
dm: rawAllowFrom,
group: rawEffective.effectiveGroupAllowFrom,
pairingStore: rawStoreAllowFrom,
commandOwner: commandOwnerAllowFrom({
command: params.command,
isGroup,
configuredAllowFrom: rawAllowFrom,
effectiveAllowFrom: rawEffective.effectiveAllowFrom,
}),
commandGroup: commandGroupAllowFrom({
command: params.command,
isGroup,
effectiveCommandGroupAllowFrom: rawCommandGroup.effectiveGroupAllowFrom,
}),
},
});
const ingress = decideChannelIngress(state, policy);
const matchedAccessGroupEntry = accessGroupMatchedEntry(params);
const effectiveAllowFrom = appendAccessGroupMatchedEntry({
entries: baseEffective.effectiveAllowFrom,
allowlist: state.allowlists.dm,
matchedEntry: matchedAccessGroupEntry,
});
const effectiveGroupAllowFrom = appendAccessGroupMatchedEntry({
entries: baseEffective.effectiveGroupAllowFrom,
allowlist: state.allowlists.group,
matchedEntry: matchedAccessGroupEntry,
});
const senderAccess = projectSenderAccess({
ingress,
isGroup,
effectiveAllowFrom,
effectiveGroupAllowFrom,
providerMissingFallbackApplied: params.providerMissingFallbackApplied,
});
const routeAccess = projectRouteAccess({ ingress, route: params.route });
const commandAccess = projectCommandAccess({ ingress, policy });
const activationAccess = projectActivationAccess({ ingress });
return {
state,
ingress,
senderAccess,
routeAccess,
commandAccess,
activationAccess,
};
}

View File

@@ -0,0 +1,166 @@
import {
allowlistFailureReason,
applyMutableIdentifierPolicy,
effectiveGroupSenderAllowlist,
redactedAllowlistDiagnostics,
} from "./allowlist.js";
import type {
AccessGraphGate,
ChannelIngressPolicyInput,
ChannelIngressState,
ResolvedIngressAllowlist,
} from "./types.js";
function senderGate(params: {
id: "sender:dm" | "sender:group";
kind: "dmSender" | "groupSender";
effect: AccessGraphGate["effect"];
allowed: boolean;
reasonCode: AccessGraphGate["reasonCode"];
match: AccessGraphGate["match"];
policy: ChannelIngressPolicyInput["dmPolicy"] | ChannelIngressPolicyInput["groupPolicy"];
allowlistSource: ResolvedIngressAllowlist;
}): AccessGraphGate {
return {
id: params.id,
phase: "sender",
kind: params.kind,
effect: params.effect,
allowed: params.allowed,
reasonCode: params.reasonCode,
match: params.match,
sender: { policy: params.policy },
allowlist: redactedAllowlistDiagnostics(params.allowlistSource, params.reasonCode),
};
}
export function senderGateForDirect(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
}): AccessGraphGate {
const dm = applyMutableIdentifierPolicy(params.state.allowlists.dm, params.policy);
const pairingStore = applyMutableIdentifierPolicy(
params.state.allowlists.pairingStore,
params.policy,
);
const base = {
policy: params.policy.dmPolicy,
allowlistSource: dm,
match: dm.match,
};
const allow = (reasonCode: AccessGraphGate["reasonCode"]) =>
senderGate({
id: "sender:dm",
kind: "dmSender",
...base,
effect: "allow",
allowed: true,
reasonCode,
});
const block = (reasonCode: AccessGraphGate["reasonCode"]) =>
senderGate({
id: "sender:dm",
kind: "dmSender",
...base,
effect: "block-dispatch",
allowed: false,
reasonCode,
});
if (params.policy.dmPolicy === "disabled") {
return block("dm_policy_disabled");
}
if (params.policy.dmPolicy === "open") {
if (dm.hasWildcard) {
return allow("dm_policy_open");
}
if (dm.match.matched) {
return allow("dm_policy_allowlisted");
}
return block("dm_policy_not_allowlisted");
}
if (dm.match.matched) {
return allow("dm_policy_allowlisted");
}
if (params.policy.dmPolicy === "pairing" && pairingStore.match.matched) {
return senderGate({
id: "sender:dm",
kind: "dmSender",
effect: "allow",
allowed: true,
reasonCode: "dm_policy_allowlisted",
match: pairingStore.match,
policy: params.policy.dmPolicy,
allowlistSource: pairingStore,
});
}
if (params.policy.dmPolicy === "pairing" && params.state.event.mayPair) {
return block("dm_policy_pairing_required");
}
const reasonCode =
params.policy.dmPolicy === "pairing"
? "event_pairing_not_allowed"
: (allowlistFailureReason(dm) ?? "dm_policy_not_allowlisted");
return block(reasonCode);
}
export function senderGateForGroup(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
}): AccessGraphGate {
const group = effectiveGroupSenderAllowlist(params);
const base = {
policy: params.policy.groupPolicy,
allowlistSource: group,
match: group.match,
};
const allow = (reasonCode: AccessGraphGate["reasonCode"]) =>
senderGate({
id: "sender:group",
kind: "groupSender",
...base,
effect: "allow",
allowed: true,
reasonCode,
});
const block = (reasonCode: AccessGraphGate["reasonCode"]) =>
senderGate({
id: "sender:group",
kind: "groupSender",
...base,
effect: "block-dispatch",
allowed: false,
reasonCode,
});
if (params.policy.groupPolicy === "disabled") {
return block("group_policy_disabled");
}
if (params.policy.groupPolicy === "open") {
return allow("group_policy_open");
}
if (!group.hasConfiguredEntries) {
return block("group_policy_empty_allowlist");
}
if (group.match.matched) {
return allow("group_policy_allowed");
}
return block(allowlistFailureReason(group) ?? "group_policy_not_allowlisted");
}
export function applyEventAuthModeToSenderGate(params: {
state: ChannelIngressState;
senderGate: AccessGraphGate;
}): AccessGraphGate {
if (params.state.event.authMode === "inbound" || params.senderGate.allowed) {
return params.senderGate;
}
const reasonCode = "sender_not_required";
return {
...params.senderGate,
effect: "ignore",
allowed: true,
reasonCode,
allowlist: params.senderGate.allowlist
? { ...params.senderGate.allowlist, reasonCode }
: undefined,
};
}

View File

@@ -0,0 +1,396 @@
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import { parseAccessGroupAllowFromEntry } from "../allow-from.js";
import type {
AccessGroupMembershipFact,
ChannelIngressState,
ChannelIngressStateInput,
InternalChannelIngressAdapter,
InternalChannelIngressSubject,
InternalNormalizedEntry,
RedactedIngressEntryDiagnostic,
RedactedIngressMatch,
ResolvedRouteGateFacts,
ResolvedIngressAllowlist,
} from "./types.js";
function redactedEntries(entries: readonly InternalNormalizedEntry[]) {
return entries.map(({ value: _value, ...entry }) => entry);
}
function emptyMatch(): RedactedIngressMatch {
return { matched: false, matchedEntryIds: [] };
}
function mergeMatches(matches: readonly RedactedIngressMatch[]): RedactedIngressMatch {
const matchedEntryIds = Array.from(new Set(matches.flatMap((match) => match.matchedEntryIds)));
return {
matched: matches.some((match) => match.matched) || matchedEntryIds.length > 0,
matchedEntryIds,
};
}
function mergeDiagnostics(
...groups: Array<readonly RedactedIngressEntryDiagnostic[] | undefined>
): RedactedIngressEntryDiagnostic[] {
const diagnostics: RedactedIngressEntryDiagnostic[] = [];
for (const group of groups) {
if (group) {
diagnostics.push(...group);
}
}
return diagnostics;
}
function accessGroupFactByName(
facts: readonly AccessGroupMembershipFact[] | undefined,
): Map<string, AccessGroupMembershipFact> {
return new Map((facts ?? []).map((fact) => [fact.groupName, fact] as const));
}
async function normalizeAndMatch(params: {
adapter: InternalChannelIngressAdapter;
subject: InternalChannelIngressSubject;
accountId: string;
entries: readonly string[];
context: "dm" | "group" | "route" | "command";
}): Promise<{
normalizedEntries: ReturnType<typeof redactedEntries>;
invalidEntries: RedactedIngressEntryDiagnostic[];
disabledEntries: RedactedIngressEntryDiagnostic[];
match: RedactedIngressMatch;
}> {
if (params.entries.length === 0) {
return {
normalizedEntries: [],
invalidEntries: [],
disabledEntries: [],
match: emptyMatch(),
};
}
const normalized = await params.adapter.normalizeEntries({
entries: params.entries,
context: params.context,
accountId: params.accountId,
});
const match =
normalized.matchable.length > 0
? await params.adapter.matchSubject({
subject: params.subject,
entries: normalized.matchable,
context: params.context,
})
: emptyMatch();
return {
normalizedEntries: redactedEntries(normalized.matchable),
invalidEntries: normalized.invalid,
disabledEntries: normalized.disabled,
match,
};
}
function referencedAccessGroups(entries: readonly string[]): string[] {
return Array.from(
new Set(
entries
.map((entry) => parseAccessGroupAllowFromEntry(entry))
.filter((entry): entry is string => entry != null),
),
);
}
function directAllowlistEntries(entries: readonly string[]): string[] {
return entries.filter((entry) => parseAccessGroupAllowFromEntry(entry) == null);
}
function groupSenderEntries(params: {
groupName: string;
input: ChannelIngressStateInput;
}): string[] {
const group = params.input.accessGroups?.[params.groupName];
if (!group || group.type !== "message.senders") {
return [];
}
return normalizeStringEntries([
...(group.members["*"] ?? []),
...(group.members[params.input.channelId] ?? []),
]);
}
function eventSubjectMatchContext(input: ChannelIngressStateInput): "dm" | "group" {
return input.conversation.kind === "direct" ? "dm" : "group";
}
async function normalizeSubjectIdentifiersForMatch(params: {
input: ChannelIngressStateInput;
subject: InternalChannelIngressSubject;
context: "dm" | "group";
opaquePrefix: string;
}): Promise<InternalNormalizedEntry[]> {
const normalized = await Promise.all(
params.subject.identifiers.map(async (identifier, identifierIndex) => {
const entries = await params.input.adapter.normalizeEntries({
entries: [identifier.value],
context: params.context,
accountId: params.input.accountId,
});
return (
entries.matchable
// Origin subjects are identity material, not configured allowlists.
// Do not let a subject value normalize into adapter wildcard semantics.
.filter((entry) => entry.kind === identifier.kind && entry.value !== "*")
.map((entry, entryIndex) => ({
opaqueEntryId: `${params.opaquePrefix}-${identifierIndex + 1}:${entryIndex + 1}`,
kind: entry.kind,
value: entry.value,
dangerous: entry.dangerous,
sensitivity: entry.sensitivity,
}))
);
}),
);
return normalized.flat();
}
async function originSubjectMatched(input: ChannelIngressStateInput): Promise<boolean> {
const origin = input.event.originSubject;
if (!origin) {
return false;
}
if (
origin.identifiers.some((identifier) =>
input.subject.identifiers.some(
(current) => current.kind === identifier.kind && current.value === identifier.value,
),
)
) {
return true;
}
const context = eventSubjectMatchContext(input);
const originEntries = await normalizeSubjectIdentifiersForMatch({
input,
subject: origin,
context,
opaquePrefix: "origin",
});
if (originEntries.length > 0) {
const currentMatch = await input.adapter.matchSubject({
subject: input.subject,
entries: originEntries,
context,
});
if (currentMatch.matched) {
return true;
}
}
const currentEntries = await normalizeSubjectIdentifiersForMatch({
input,
subject: input.subject,
context,
opaquePrefix: "current",
});
if (currentEntries.length === 0) {
return false;
}
const originMatch = await input.adapter.matchSubject({
subject: origin,
entries: currentEntries,
context,
});
return originMatch.matched;
}
async function resolveAccessGroupEntries(params: {
input: ChannelIngressStateInput;
context: "dm" | "group" | "route" | "command";
referenced: readonly string[];
}): Promise<{
normalizedEntries: ReturnType<typeof redactedEntries>;
invalidEntries: RedactedIngressEntryDiagnostic[];
disabledEntries: RedactedIngressEntryDiagnostic[];
matches: RedactedIngressMatch[];
accessGroups: ResolvedIngressAllowlist["accessGroups"];
}> {
const factByName = accessGroupFactByName(params.input.accessGroupMembership);
const accessGroups: ResolvedIngressAllowlist["accessGroups"] = {
referenced: [...params.referenced],
matched: [],
missing: [],
unsupported: [],
failed: [],
};
const normalizedEntries: ReturnType<typeof redactedEntries> = [];
const invalidEntries: RedactedIngressEntryDiagnostic[] = [];
const disabledEntries: RedactedIngressEntryDiagnostic[] = [];
const matches: RedactedIngressMatch[] = [];
for (const groupName of params.referenced) {
const fact = factByName.get(groupName);
if (fact?.kind === "matched") {
accessGroups.matched.push(groupName);
matches.push({ matched: true, matchedEntryIds: fact.matchedEntryIds });
continue;
}
if (fact?.kind === "missing" || fact?.kind === "unsupported" || fact?.kind === "failed") {
accessGroups[fact.kind].push(groupName);
continue;
}
if (fact?.kind === "not-matched") {
continue;
}
const group = params.input.accessGroups?.[groupName];
if (!group) {
accessGroups.missing.push(groupName);
continue;
}
if (group.type !== "message.senders") {
accessGroups.unsupported.push(groupName);
continue;
}
const groupEntries = groupSenderEntries({ groupName, input: params.input });
const resolved = await normalizeAndMatch({
adapter: params.input.adapter,
subject: params.input.subject,
accountId: params.input.accountId,
entries: groupEntries,
context: params.context,
});
normalizedEntries.push(...resolved.normalizedEntries);
invalidEntries.push(...resolved.invalidEntries);
disabledEntries.push(...resolved.disabledEntries);
if (resolved.match.matched) {
accessGroups.matched.push(groupName);
matches.push(resolved.match);
}
}
return {
normalizedEntries,
invalidEntries,
disabledEntries,
matches,
accessGroups,
};
}
async function resolveIngressAllowlist(params: {
input: ChannelIngressStateInput;
rawEntries: Array<string | number> | undefined;
context: "dm" | "group" | "route" | "command";
}): Promise<ResolvedIngressAllowlist> {
const entries = normalizeStringEntries(params.rawEntries ?? []);
const referenced = referencedAccessGroups(entries);
const directEntries = directAllowlistEntries(entries);
const direct = await normalizeAndMatch({
adapter: params.input.adapter,
subject: params.input.subject,
accountId: params.input.accountId,
entries: directEntries,
context: params.context,
});
const groups = await resolveAccessGroupEntries({
input: params.input,
context: params.context,
referenced,
});
const match = mergeMatches([direct.match, ...groups.matches]);
return {
rawEntryCount: entries.length,
normalizedEntries: [...direct.normalizedEntries, ...groups.normalizedEntries],
invalidEntries: mergeDiagnostics(direct.invalidEntries, groups.invalidEntries),
disabledEntries: mergeDiagnostics(direct.disabledEntries, groups.disabledEntries),
matchedEntryIds: match.matchedEntryIds,
hasConfiguredEntries: entries.length > 0,
hasMatchableEntries: direct.normalizedEntries.length > 0 || groups.normalizedEntries.length > 0,
hasWildcard: directEntries.includes("*"),
accessGroups: groups.accessGroups,
match,
};
}
async function resolveRouteFacts(
input: ChannelIngressStateInput,
): Promise<ResolvedRouteGateFacts[]> {
const routeFacts = [...(input.routeFacts ?? [])].toSorted(
(left, right) => left.precedence - right.precedence || left.id.localeCompare(right.id),
);
const resolved: ResolvedRouteGateFacts[] = [];
for (const route of routeFacts) {
const senderAllowFrom =
route.senderAllowFrom ??
(route.senderAllowFromSource === "effective-dm"
? input.allowlists.dm
: route.senderAllowFromSource === "effective-group"
? input.allowlists.group
: undefined);
resolved.push({
id: route.id,
kind: route.kind,
gate: route.gate,
effect: route.effect,
precedence: route.precedence,
senderPolicy: route.senderPolicy,
match: route.match,
senderAllowlist:
senderAllowFrom != null
? await resolveIngressAllowlist({
input,
rawEntries: senderAllowFrom,
context: "route",
})
: undefined,
});
}
return resolved;
}
export async function resolveChannelIngressState(
input: ChannelIngressStateInput,
): Promise<ChannelIngressState> {
const [dm, pairingStore, group, commandOwner, commandGroup, routeFacts, eventOriginMatched] =
await Promise.all([
resolveIngressAllowlist({ input, rawEntries: input.allowlists.dm, context: "dm" }),
resolveIngressAllowlist({
input,
rawEntries: input.allowlists.pairingStore,
context: "dm",
}),
resolveIngressAllowlist({ input, rawEntries: input.allowlists.group, context: "group" }),
resolveIngressAllowlist({
input,
rawEntries: input.allowlists.commandOwner,
context: "command",
}),
resolveIngressAllowlist({
input,
rawEntries: input.allowlists.commandGroup,
context: "command",
}),
resolveRouteFacts(input),
originSubjectMatched(input),
]);
return {
channelId: input.channelId,
accountId: input.accountId,
conversationKind: input.conversation.kind,
event: {
kind: input.event.kind,
authMode: input.event.authMode,
mayPair: input.event.mayPair,
hasOriginSubject: input.event.originSubject != null,
originSubjectMatched: eventOriginMatched,
},
mentionFacts: input.mentionFacts,
routeFacts,
allowlists: {
dm,
pairingStore,
group,
commandOwner,
commandGroup,
},
};
}

View File

@@ -0,0 +1,369 @@
import type { AccessGroupConfig } from "../../config/types.access-groups.js";
import type { ChatChannelId } from "../ids.js";
import type { InboundImplicitMentionKind, InboundMentionFacts } from "../mention-gating.js";
/** Channel identifier used in ingress diagnostics and config lookups. */
export type ChannelIngressChannelId = ChatChannelId;
/** Redacted identifier category used by allowlist normalization and matching. */
export type ChannelIngressIdentifierKind =
| "stable-id"
| "username"
| "email"
| "phone"
| "role"
| `plugin:${string}`;
/** Public, redacted identifier material that can participate in allowlist matching. */
export type MatchableIdentifier = {
opaqueId: string;
kind: ChannelIngressIdentifierKind;
dangerous?: boolean;
sensitivity?: "normal" | "pii";
};
/** Internal identifier material with the raw comparable value retained. */
export type InternalMatchMaterial = MatchableIdentifier & {
value: string;
};
/** Internal subject representation used by the shared ingress kernel. */
export type InternalChannelIngressSubject = {
identifiers: InternalMatchMaterial[];
};
/** Public, redacted form of a normalized allowlist entry. */
export type ChannelIngressNormalizedEntry = {
opaqueEntryId: string;
kind: ChannelIngressIdentifierKind;
dangerous?: boolean;
sensitivity?: "normal" | "pii";
};
/** Internal normalized allowlist entry with its raw comparable value retained. */
export type InternalNormalizedEntry = ChannelIngressNormalizedEntry & {
value: string;
};
/** Redacted diagnostic for an invalid, disabled, or unsupported allowlist entry. */
export type RedactedIngressEntryDiagnostic = {
opaqueEntryId?: string;
reasonCode: IngressReasonCode;
};
/** Redacted allowlist match result exposed to callers and access facts. */
export type RedactedIngressMatch = {
matched: boolean;
matchedEntryIds: string[];
};
/** Public normalization result for a set of allowlist entries. */
export type ChannelIngressNormalizeResult = {
matchable: ChannelIngressNormalizedEntry[];
invalid: RedactedIngressEntryDiagnostic[];
disabled: RedactedIngressEntryDiagnostic[];
};
/** Internal normalization result with raw comparable entry values retained. */
export type InternalChannelIngressNormalizeResult = Omit<
ChannelIngressNormalizeResult,
"matchable"
> & {
matchable: InternalNormalizedEntry[];
};
/** Adapter that gives the shared ingress kernel channel-specific identity matching. */
export type InternalChannelIngressAdapter = {
normalizeEntries(params: {
entries: readonly string[];
context: "dm" | "group" | "route" | "command";
accountId: string;
}): InternalChannelIngressNormalizeResult | Promise<InternalChannelIngressNormalizeResult>;
matchSubject(params: {
subject: InternalChannelIngressSubject;
entries: readonly InternalNormalizedEntry[];
context: "dm" | "group" | "route" | "command";
}): RedactedIngressMatch | Promise<RedactedIngressMatch>;
};
/** Resolved access-group membership fact used by allowlist entries. */
export type AccessGroupMembershipFact =
| {
kind: "matched";
groupName: string;
source: "static" | "dynamic";
matchedEntryIds: string[];
}
| {
kind: "not-matched";
groupName: string;
source: "static" | "dynamic";
}
| {
kind: "missing" | "unsupported" | "failed";
groupName: string;
source: "static" | "dynamic";
reasonCode: IngressReasonCode;
diagnosticId?: string;
};
/** Fully normalized allowlist facts for one ingress gate. */
export type ResolvedIngressAllowlist = {
rawEntryCount: number;
normalizedEntries: ChannelIngressNormalizedEntry[];
invalidEntries: RedactedIngressEntryDiagnostic[];
disabledEntries: RedactedIngressEntryDiagnostic[];
matchedEntryIds: string[];
hasConfiguredEntries: boolean;
hasMatchableEntries: boolean;
hasWildcard: boolean;
accessGroups: {
referenced: string[];
matched: string[];
missing: string[];
unsupported: string[];
failed: string[];
};
match: RedactedIngressMatch;
};
/** Redacted allowlist facts safe to expose in the access graph. */
export type RedactedIngressAllowlistFacts = {
configured: boolean;
matched: boolean;
reasonCode: IngressReasonCode;
matchedEntryIds: string[];
invalidEntryCount: number;
disabledEntryCount: number;
accessGroups: ResolvedIngressAllowlist["accessGroups"];
};
/** Route lookup state projected into the ingress access graph. */
export type RouteGateState =
| "not-configured"
| "matched"
| "not-matched"
| "disabled"
| "lookup-failed";
/** How a matched route affects sender allowlist evaluation. */
export type RouteSenderPolicy = "inherit" | "replace" | "deny-when-empty";
/** Source list used when a route sender policy contributes sender entries. */
export type RouteSenderAllowlistSource = "effective-dm" | "effective-group";
/** Raw route gate facts supplied by a channel-specific router. */
export type RouteGateFacts = {
id: string;
kind: "route" | "routeSender" | "membership" | "ownerAllowlist" | "nestedAllowlist";
gate: RouteGateState;
effect: "allow" | "block-dispatch" | "ignore";
precedence: number;
senderPolicy: RouteSenderPolicy;
senderAllowFrom?: Array<string | number>;
senderAllowFromSource?: RouteSenderAllowlistSource;
match?: RedactedIngressMatch;
};
/** Route gate facts after any route-specific sender allowlist is normalized. */
export type ResolvedRouteGateFacts = Omit<
RouteGateFacts,
"senderAllowFrom" | "senderAllowFromSource"
> & {
senderAllowlist?: ResolvedIngressAllowlist;
};
/** Inbound event facts used to choose command, pairing, and origin-subject rules. */
export type ChannelIngressEventInput = {
kind:
| "message"
| "reaction"
| "button"
| "postback"
| "native-command"
| "slash-command"
| "system";
authMode: "inbound" | "command" | "origin-subject" | "route-only" | "none";
mayPair: boolean;
originSubject?: InternalChannelIngressSubject;
};
/** Redacted event facts exposed in decisions and access facts. */
export type RedactedChannelIngressEvent = Omit<ChannelIngressEventInput, "originSubject"> & {
hasOriginSubject: boolean;
originSubjectMatched: boolean;
};
/** Complete raw input to the shared ingress state resolver. */
export type ChannelIngressStateInput = {
channelId: ChannelIngressChannelId;
accountId: string;
subject: InternalChannelIngressSubject;
conversation: {
kind: "direct" | "group" | "channel";
id: string;
parentId?: string;
threadId?: string;
title?: string;
};
adapter: InternalChannelIngressAdapter;
accessGroups?: Record<string, AccessGroupConfig>;
accessGroupMembership?: readonly AccessGroupMembershipFact[];
routeFacts?: RouteGateFacts[];
mentionFacts?: InboundMentionFacts;
event: ChannelIngressEventInput;
allowlists: {
dm?: Array<string | number>;
group?: Array<string | number>;
commandOwner?: Array<string | number>;
commandGroup?: Array<string | number>;
pairingStore?: Array<string | number>;
};
};
/** Policy knobs that decide how the ingress graph is evaluated. */
export type ChannelIngressPolicyInput = {
dmPolicy: "pairing" | "allowlist" | "open" | "disabled";
groupPolicy: "allowlist" | "open" | "disabled";
groupAllowFromFallbackToAllowFrom?: boolean;
mutableIdentifierMatching?: "disabled" | "enabled";
activation?: {
requireMention: boolean;
allowTextCommands: boolean;
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
order?: "before-sender" | "after-command";
};
command?: {
useAccessGroups?: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
};
};
/** Ordered phase for a gate in the ingress graph. */
export type IngressGatePhase = "route" | "sender" | "command" | "event" | "activation";
/** Gate kind used in the ingress graph and projected access facts. */
export type IngressGateKind =
| "route"
| "routeSender"
| "dmSender"
| "groupSender"
| "membership"
| "ownerAllowlist"
| "nestedAllowlist"
| "command"
| "event"
| "mention";
/** Effect produced by a gate when computing final ingress admission. */
export type IngressGateEffect =
| "allow"
| "block-dispatch"
| "block-command"
| "skip"
| "observe"
| "ignore";
/** Stable machine-readable reason code for ingress diagnostics. */
export type IngressReasonCode =
| "allowed"
| "route_blocked"
| "route_sender_empty"
| "dm_policy_disabled"
| "dm_policy_open"
| "dm_policy_allowlisted"
| "dm_policy_pairing_required"
| "dm_policy_not_allowlisted"
| "group_policy_disabled"
| "group_policy_open"
| "group_policy_allowed"
| "group_policy_empty_allowlist"
| "group_policy_not_allowlisted"
| "command_authorized"
| "control_command_unauthorized"
| "event_authorized"
| "event_unauthorized"
| "event_pairing_not_allowed"
| "sender_not_required"
| "origin_subject_missing"
| "origin_subject_not_matched"
| "activation_allowed"
| "activation_skipped"
| "access_group_missing"
| "access_group_unsupported"
| "access_group_failed"
| "mutable_identifier_disabled"
| "no_policy_match";
/** One evaluated gate in the ordered ingress access graph. */
export type AccessGraphGate = {
id: string;
phase: IngressGatePhase;
kind: IngressGateKind;
effect: IngressGateEffect;
allowed: boolean;
reasonCode: IngressReasonCode;
match?: RedactedIngressMatch;
allowlist?: RedactedIngressAllowlistFacts;
sender?: {
policy: ChannelIngressPolicyInput["dmPolicy"] | ChannelIngressPolicyInput["groupPolicy"];
};
command?: {
useAccessGroups: boolean;
allowTextCommands: boolean;
modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
shouldBlockControlCommand: boolean;
};
event?: RedactedChannelIngressEvent;
activation?: {
hasMentionFacts: boolean;
requireMention: boolean;
allowTextCommands: boolean;
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
order?: "before-sender" | "after-command";
shouldSkip: boolean;
canDetectMention?: boolean;
wasMentioned?: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
effectiveWasMentioned?: boolean;
shouldBypassMention?: boolean;
};
};
/** Ordered graph of all evaluated ingress gates. */
export type AccessGraph = {
gates: AccessGraphGate[];
};
/** Normalized ingress state before policy gates are reduced into a decision. */
export type ChannelIngressState = {
channelId: ChannelIngressChannelId;
accountId: string;
conversationKind: "direct" | "group" | "channel";
event: RedactedChannelIngressEvent;
mentionFacts?: InboundMentionFacts;
routeFacts: ResolvedRouteGateFacts[];
allowlists: {
dm: ResolvedIngressAllowlist;
pairingStore: ResolvedIngressAllowlist;
group: ResolvedIngressAllowlist;
commandOwner: ResolvedIngressAllowlist;
commandGroup: ResolvedIngressAllowlist;
};
};
/** Final runtime admission action for the inbound event. */
export type ChannelIngressAdmission = "dispatch" | "observe" | "skip" | "drop" | "pairing-required";
/** Final decision and graph for a resolved channel ingress event. */
export type ChannelIngressDecision = {
admission: ChannelIngressAdmission;
decision: "allow" | "block" | "pairing";
decisiveGateId: string;
reasonCode: IngressReasonCode;
graph: AccessGraph;
};

View File

@@ -177,6 +177,41 @@ describe("buildChannelTurnContext", () => {
}
});
it("uses resolved command authorization instead of recomputing authorizers", () => {
const ctx = buildChannelTurnContext(
createBaseContextParams({
access: {
commands: {
authorized: false,
shouldBlockControlCommand: true,
reasonCode: "control_command_unauthorized",
allowTextCommands: true,
useAccessGroups: true,
authorizers: [{ configured: true, allowed: true }],
},
},
}),
);
expect(ctx.CommandAuthorized).toBe(false);
});
it("keeps legacy command authorization fallback for authorizer arrays", () => {
const ctx = buildChannelTurnContext(
createBaseContextParams({
access: {
commands: {
allowTextCommands: true,
useAccessGroups: true,
authorizers: [{ configured: true, allowed: true }],
},
},
}),
);
expect(ctx.CommandAuthorized).toBe(true);
});
it("filters supplemental context with channel visibility policy", () => {
const ctx = buildChannelTurnContext(
createBaseContextParams({

View File

@@ -46,14 +46,6 @@ function mediaTranscribedIndexes(media: InboundMediaFacts[]): number[] | undefin
return indexes.length > 0 ? indexes : undefined;
}
function commandAuthorized(access: AccessFacts | undefined): boolean | undefined {
const commands = access?.commands;
if (!commands) {
return undefined;
}
return commands.authorizers.some((entry) => entry.allowed);
}
function keepSupplementalContext(params: {
mode?: ContextVisibilityMode;
kind: "quote" | "forwarded" | "thread";
@@ -110,6 +102,13 @@ export function filterChannelTurnSupplementalContext(params: {
};
}
function resolveAccessFactsCommandAuthorized(access: AccessFacts | undefined): boolean | undefined {
const commands = access?.commands;
return typeof commands?.authorized === "boolean"
? commands.authorized
: commands?.authorizers?.some((entry) => entry.allowed);
}
export function buildChannelTurnContext(
params: BuildChannelTurnContextParams,
): FinalizedMsgContext {
@@ -174,7 +173,7 @@ export function buildChannelTurnContext(
Provider: params.provider ?? params.channel,
Surface: params.surface ?? params.provider ?? params.channel,
WasMentioned: params.access?.mentions?.wasMentioned,
CommandAuthorized: commandAuthorized(params.access),
CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access),
MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId,
NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId,
OriginatingChannel: params.channel,

View File

@@ -89,29 +89,86 @@ export type ReplyPlanFacts = {
sourceReplyDeliveryMode?: "thread" | "reply" | "channel" | "direct" | "none";
};
export type ProjectedAllowlistAccessFacts = {
configured: boolean;
matched: boolean;
reasonCode?: string;
matchedEntryIds: string[];
invalidEntryCount: number;
disabledEntryCount: number;
accessGroups: {
referenced: string[];
matched: string[];
missing: string[];
unsupported: string[];
failed: string[];
};
};
export type ProjectedEventAccessFacts = {
kind:
| "message"
| "reaction"
| "button"
| "postback"
| "native-command"
| "slash-command"
| "system";
authMode: "inbound" | "command" | "origin-subject" | "route-only" | "none";
mayPair: boolean;
authorized: boolean;
reasonCode?: string;
hasOriginSubject: boolean;
originSubjectMatched: boolean;
};
export type AccessFacts = {
dm?: {
decision: "allow" | "pairing" | "deny";
reason?: string;
/**
* @deprecated Shared ingress projections redact allowlist entries and return an empty compat list.
* Use allowlist diagnostics instead.
*/
allowFrom: string[];
allowlist?: ProjectedAllowlistAccessFacts;
};
group?: {
policy: "open" | "allowlist" | "disabled";
routeAllowed: boolean;
senderAllowed: boolean;
/**
* @deprecated Shared ingress projections redact allowlist entries and return an empty compat list.
* Use allowlist diagnostics instead.
*/
allowFrom: string[];
requireMention: boolean;
allowlist?: ProjectedAllowlistAccessFacts;
};
commands?: {
authorized?: boolean;
shouldBlockControlCommand?: boolean;
reasonCode?: string;
useAccessGroups: boolean;
allowTextCommands: boolean;
modeWhenAccessGroupsOff?: "allow" | "deny" | "configured";
/**
* @deprecated Shared ingress projections do not expose raw authorizer lists.
* Use authorized and reasonCode instead.
*/
authorizers: Array<{ configured: boolean; allowed: boolean }>;
};
event?: ProjectedEventAccessFacts;
mentions?: {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: Array<"reply_to_bot" | "bot_thread_participant" | "native">;
implicitMentionKinds?: Array<
"reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native"
>;
requireMention?: boolean;
effectiveWasMentioned?: boolean;
shouldSkip?: boolean;
};
};

View File

@@ -1,3 +1,4 @@
import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js";
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -9,7 +10,6 @@ import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js";
import { loadExecApprovals, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
import { collectExecFilesystemPolicyDriftHits } from "../security/exec-filesystem-policy.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { note } from "../terminal/note.js";
@@ -285,7 +285,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
}) => {
const dmPolicy = params.dmPolicy;
const policyPath = params.policyPath ?? `${params.allowFromPath}policy`;
const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowState({
const { hasWildcard, allowCount, isMultiUserDm } = await resolveDmAllowAuditState({
provider: params.provider,
accountId: params.accountId,
allowFrom: params.allowFrom,

View File

@@ -24,9 +24,8 @@ export function applyNonInteractiveGatewayConfig(params: {
const { opts, runtime } = params;
const gatewayPort = opts.gatewayPort;
const hasGatewayPort = gatewayPort !== undefined;
if (
hasGatewayPort &&
gatewayPort !== undefined &&
(!Number.isFinite(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65_535)
) {
runtime.error(formatInvalidPortOption("--gateway-port"));

View File

@@ -4,7 +4,7 @@ import {
getTotalPendingReplies,
} from "../auto-reply/reply/dispatcher-registry.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getTotalQueueSize } from "../process/command-queue.js";
import { getTotalQueueSize, resetCommandQueueStateForTest } from "../process/command-queue.js";
async function flushMicrotasks(count = 10): Promise<void> {
for (let i = 0; i < count; i += 1) {
@@ -29,6 +29,7 @@ describe("gateway restart deferral", () => {
let replyErrors: string[] = [];
beforeEach(() => {
resetCommandQueueStateForTest();
vi.clearAllMocks();
replyErrors = [];
});
@@ -37,6 +38,7 @@ describe("gateway restart deferral", () => {
vi.restoreAllMocks();
await flushMicrotasks();
clearAllDispatchers();
resetCommandQueueStateForTest();
});
it("defers restart while reply delivery is in flight", async () => {

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({

View File

@@ -49,7 +49,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { createAccountStatusSink, runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";',
'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";',
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";',
'export { evaluateGroupRouteAccessForPolicy, resolveDmGroupAccessWithLists, resolveSenderScopedGroupPolicy } from "openclaw/plugin-sdk/channel-policy";',
'export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";',
'export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";',
'export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";',
@@ -77,7 +76,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";',
'export { logTypingFailure } from "openclaw/plugin-sdk/channel-logging";',
'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";',
'export { evaluateSenderGroupAccessForPolicy, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, resolveSenderScopedGroupPolicy, resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";',
'export { resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";',
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";',
'export { PAIRING_APPROVED_MESSAGE, buildProbeChannelStatusSummary, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/channel-status";',
'export { buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision } from "openclaw/plugin-sdk/channel-targets";',
@@ -131,7 +130,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";',
'export { logInboundDrop } from "openclaw/plugin-sdk/channel-logging";',
'export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";',
'export { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate } from "openclaw/plugin-sdk/channel-policy";',
'export type { BlockStreamingCoalesceConfig, DmConfig, DmPolicy, GroupPolicy, GroupToolPolicyConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";',
'export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";',
'export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";',

View File

@@ -174,6 +174,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
},
turn: {
run: runChannelTurn,
runAssembled: dispatchAssembledChannelTurn,
runResolved: runResolvedChannelTurn,
buildContext: buildChannelTurnContext,
runPrepared: runPreparedChannelTurn,

View File

@@ -153,11 +153,12 @@ export type PluginRuntimeChannel = {
};
turn: {
run: typeof import("../../channels/turn/kernel.js").runChannelTurn;
runAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn;
/** @deprecated Prefer `run(...)`. */
runResolved: typeof import("../../channels/turn/kernel.js").runResolvedChannelTurn;
buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext;
runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn;
/** @deprecated Prefer `run(...)` or `runPrepared(...)`. */
/** @deprecated Prefer `runAssembled(...)`. */
dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn;
};
threadBindings: {

View File

@@ -2,6 +2,7 @@ import {
hasConfiguredUnavailableCredentialStatus,
hasResolvedCredentialValue,
} from "../channels/account-snapshot-fields.js";
import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
@@ -11,7 +12,6 @@ import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.types.js";
import { resolveDmAllowState } from "./dm-policy-shared.js";
function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity {
const s = message.toLowerCase();
@@ -206,7 +206,7 @@ export async function collectChannelSecurityFindings(params: {
normalizeEntry?: (raw: string) => string;
}) => {
const policyPath = input.policyPath ?? `${input.allowFromPath}policy`;
const { hasWildcard, isMultiUserDm } = await resolveDmAllowState({
const { hasWildcard, isMultiUserDm } = await resolveDmAllowAuditState({
provider: input.provider,
accountId: input.accountId,
allowFrom: input.allowFrom,

View File

@@ -1,593 +0,0 @@
import { describe, expect, it } from "vitest";
import {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmAllowState,
resolveDmGroupAccessWithCommandGate,
resolveDmGroupAccessDecision,
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
resolvePinnedMainDmOwnerFromAllowlist,
} from "./dm-policy-shared.js";
describe("security/dm-policy-shared", () => {
const controlCommand = {
useAccessGroups: true,
allowTextCommands: true,
hasControlCommand: true,
} as const;
async function expectStoreReadSkipped(params: {
provider: string;
accountId: string;
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
shouldRead?: boolean;
}) {
let called = false;
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: params.provider,
accountId: params.accountId,
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
...(params.shouldRead !== undefined ? { shouldRead: params.shouldRead } : {}),
readStore: async (_provider, _accountId) => {
called = true;
return ["should-not-be-read"];
},
});
expect(called).toBe(false);
expect(storeAllowFrom).toStrictEqual([]);
}
function resolveCommandGate(overrides: {
isGroup: boolean;
isSenderAllowed: (allowFrom: string[]) => boolean;
groupPolicy?: "open" | "allowlist" | "disabled";
}) {
return resolveDmGroupAccessWithCommandGate({
dmPolicy: "pairing",
groupPolicy: overrides.groupPolicy ?? "allowlist",
allowFrom: ["owner"],
groupAllowFrom: ["group-owner"],
storeAllowFrom: ["paired-user"],
command: controlCommand,
...overrides,
});
}
it("normalizes config + store allow entries and counts distinct senders", async () => {
const state = await resolveDmAllowState({
provider: "demo-channel-a" as never,
accountId: "default",
allowFrom: [" * ", " alice ", "ALICE", "bob"],
normalizeEntry: (value) => value.toLowerCase(),
readStore: async (_provider, _accountId) => [" Bob ", "carol", ""],
});
expect(state.configAllowFrom).toEqual(["*", "alice", "ALICE", "bob"]);
expect(state.hasWildcard).toBe(true);
expect(state.allowCount).toBe(3);
expect(state.isMultiUserDm).toBe(true);
});
it("handles empty allowlists and store failures", async () => {
const state = await resolveDmAllowState({
provider: "demo-channel-b" as never,
accountId: "default",
allowFrom: undefined,
readStore: async (_provider, _accountId) => {
throw new Error("offline");
},
});
expect(state.configAllowFrom).toStrictEqual([]);
expect(state.hasWildcard).toBe(false);
expect(state.allowCount).toBe(0);
expect(state.isMultiUserDm).toBe(false);
});
it("does not count pairing-store senders for allowlist DM policy", async () => {
let called = false;
const state = await resolveDmAllowState({
provider: "demo-channel-c" as never,
accountId: "default",
dmPolicy: "allowlist",
allowFrom: ["owner"],
readStore: async (_provider, _accountId) => {
called = true;
return ["paired-user"];
},
});
expect(called).toBe(false);
expect(state.allowCount).toBe(1);
expect(state.isMultiUserDm).toBe(false);
});
it.each([
{
name: "dmPolicy is allowlist",
params: {
provider: "demo-channel-a",
accountId: "default",
dmPolicy: "allowlist" as const,
},
},
{
name: "dmPolicy is open",
params: {
provider: "demo-channel-open",
accountId: "default",
dmPolicy: "open" as const,
},
},
{
name: "shouldRead=false",
params: {
provider: "demo-channel-b",
accountId: "default",
shouldRead: false,
},
},
] as const)("skips pairing-store reads when $name", async ({ params }) => {
await expectStoreReadSkipped(params);
});
it("builds effective DM/group allowlists from config + pairing store", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: [" owner ", "", "owner2"],
groupAllowFrom: ["group:abc"],
storeAllowFrom: [" owner3 ", ""],
});
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
});
it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: [" owner "],
groupAllowFrom: [],
storeAllowFrom: [" owner2 "],
});
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]);
});
it("can keep group allowlist empty when fallback is disabled", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: ["owner"],
groupAllowFrom: [],
storeAllowFrom: ["paired-user"],
groupAllowFromFallbackToAllowFrom: false,
});
expect(lists.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
expect(lists.effectiveGroupAllowFrom).toStrictEqual([]);
});
it("infers pinned main DM owner from a single configured allowlist entry", () => {
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: "main",
allowFrom: [" line:user:U123 "],
normalizeEntry: (entry) =>
entry
.trim()
.toLowerCase()
.replace(/^line:(?:user:)?/, ""),
});
expect(pinnedOwner).toBe("u123");
});
it.each([
{
name: "wildcard allowlist",
dmScope: "main" as const,
allowFrom: ["*"],
},
{
name: "multi-owner allowlist",
dmScope: "main" as const,
allowFrom: ["u123", "u456"],
},
{
name: "non-main scope",
dmScope: "per-channel-peer" as const,
allowFrom: ["u123"],
},
] as const)("does not infer pinned owner for $name", ({ dmScope, allowFrom }) => {
expect(
resolvePinnedMainDmOwnerFromAllowlist({
dmScope,
allowFrom: [...allowFrom],
normalizeEntry: (entry) => entry.trim(),
}),
).toBeNull();
});
it("excludes storeAllowFrom when dmPolicy is allowlist", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: ["+1111"],
groupAllowFrom: ["group:abc"],
storeAllowFrom: ["+2222", "+3333"],
dmPolicy: "allowlist",
});
expect(lists.effectiveAllowFrom).toEqual(["+1111"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
});
it("excludes pairing-store entries when dmPolicy is open", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: ["owner"],
groupAllowFrom: ["group:abc"],
storeAllowFrom: ["paired-user"],
dmPolicy: "open",
});
expect(lists.effectiveAllowFrom).toEqual(["owner"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
});
it("keeps group allowlist explicit when dmPolicy is pairing", () => {
const lists = resolveEffectiveAllowFromLists({
allowFrom: ["+1111"],
groupAllowFrom: [],
storeAllowFrom: ["+2222"],
dmPolicy: "pairing",
});
expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]);
expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]);
});
it("resolves access + effective allowlists in one shared call", () => {
const resolved = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: "pairing",
groupPolicy: "allowlist",
allowFrom: ["owner"],
groupAllowFrom: ["group:room"],
storeAllowFrom: ["paired-user"],
isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
});
expect(resolved.decision).toBe("allow");
expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)");
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]);
});
it("resolves command gate with dm/group parity for groups", () => {
const resolved = resolveCommandGate({
isGroup: true,
isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
});
expect(resolved.decision).toBe("block");
expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)");
expect(resolved.commandAuthorized).toBe(false);
expect(resolved.shouldBlockControlCommand).toBe(true);
});
it("keeps configured dm allowlist usable for group command auth", () => {
const resolved = resolveDmGroupAccessWithCommandGate({
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "open",
allowFrom: ["owner"],
groupAllowFrom: [],
storeAllowFrom: ["paired-user"],
isSenderAllowed: (allowFrom) => allowFrom.includes("owner"),
command: controlCommand,
});
expect(resolved.commandAuthorized).toBe(true);
expect(resolved.shouldBlockControlCommand).toBe(false);
});
it("treats dm command authorization as dm access result", () => {
const resolved = resolveCommandGate({
isGroup: false,
isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
});
expect(resolved.decision).toBe("allow");
expect(resolved.commandAuthorized).toBe(true);
expect(resolved.shouldBlockControlCommand).toBe(false);
});
it("does not auto-authorize dm commands in open mode without explicit allowlists", () => {
const resolved = resolveDmGroupAccessWithCommandGate({
isGroup: false,
dmPolicy: "open",
groupPolicy: "allowlist",
allowFrom: [],
groupAllowFrom: [],
storeAllowFrom: [],
isSenderAllowed: () => false,
command: controlCommand,
});
expect(resolved.decision).toBe("block");
expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED);
expect(resolved.reason).toBe("dmPolicy=open (not allowlisted)");
expect(resolved.commandAuthorized).toBe(false);
expect(resolved.shouldBlockControlCommand).toBe(false);
});
it("allows open-mode DMs only for wildcard or matching allowlist entries", () => {
const publicAccess = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: "open",
allowFrom: ["*"],
isSenderAllowed: () => true,
});
expect(publicAccess.decision).toBe("allow");
expect(publicAccess.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN);
const constrainedAccess = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: "open",
allowFrom: ["owner"],
isSenderAllowed: (allowFrom) => allowFrom.includes("owner"),
});
expect(constrainedAccess.decision).toBe("allow");
expect(constrainedAccess.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED);
expect(constrainedAccess.reason).toBe("dmPolicy=open (allowlisted)");
});
it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {
const resolved = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
allowFrom: ["owner"],
groupAllowFrom: [],
storeAllowFrom: ["paired-user"],
isSenderAllowed: () => false,
});
expect(resolved.decision).toBe("block");
expect(resolved.reasonCode).toBe(DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED);
expect(resolved.reason).toBe("dmPolicy=allowlist (not allowlisted)");
expect(resolved.effectiveAllowFrom).toEqual(["owner"]);
});
const channels = [
"imessage",
"imessage",
"signal",
"telegram",
"whatsapp",
"msteams",
"matrix",
"zalo",
] as const;
type ParityCase = {
name: string;
isGroup: boolean;
dmPolicy: "open" | "allowlist" | "pairing" | "disabled";
groupPolicy: "open" | "allowlist" | "disabled";
allowFrom: string[];
groupAllowFrom: string[];
storeAllowFrom: string[];
isSenderAllowed: (allowFrom: string[]) => boolean;
expectedDecision: "allow" | "block" | "pairing";
expectedReactionAllowed: boolean;
};
type DecisionCase = {
name: string;
input: Parameters<typeof resolveDmGroupAccessDecision>[0];
expected:
| ReturnType<typeof resolveDmGroupAccessDecision>
| Pick<ReturnType<typeof resolveDmGroupAccessDecision>, "decision">;
};
function createParityCase({
name,
...overrides
}: Partial<ParityCase> & Pick<ParityCase, "name">): ParityCase {
return {
name,
isGroup: false,
dmPolicy: "open",
groupPolicy: "allowlist",
allowFrom: [],
groupAllowFrom: [],
storeAllowFrom: [],
isSenderAllowed: () => false,
expectedDecision: "allow",
expectedReactionAllowed: true,
...overrides,
};
}
function expectParityCase(channel: (typeof channels)[number], testCase: ParityCase) {
const access = resolveDmGroupAccessWithLists({
isGroup: testCase.isGroup,
dmPolicy: testCase.dmPolicy,
groupPolicy: testCase.groupPolicy,
allowFrom: testCase.allowFrom,
groupAllowFrom: testCase.groupAllowFrom,
storeAllowFrom: testCase.storeAllowFrom,
isSenderAllowed: testCase.isSenderAllowed,
});
const reactionAllowed = access.decision === "allow";
expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);
expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe(
testCase.expectedReactionAllowed,
);
}
it.each(
channels.flatMap((channel) =>
[
createParityCase({
name: "dmPolicy=open without wildcard",
dmPolicy: "open",
expectedDecision: "block",
expectedReactionAllowed: false,
}),
createParityCase({
name: "dmPolicy=open with wildcard",
dmPolicy: "open",
allowFrom: ["*"],
isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("*"),
expectedDecision: "allow",
expectedReactionAllowed: true,
}),
createParityCase({
name: "dmPolicy=disabled",
dmPolicy: "disabled",
expectedDecision: "block",
expectedReactionAllowed: false,
}),
createParityCase({
name: "dmPolicy=allowlist unauthorized",
dmPolicy: "allowlist",
allowFrom: ["owner"],
isSenderAllowed: () => false,
expectedDecision: "block",
expectedReactionAllowed: false,
}),
createParityCase({
name: "dmPolicy=allowlist authorized",
dmPolicy: "allowlist",
allowFrom: ["owner"],
isSenderAllowed: () => true,
expectedDecision: "allow",
expectedReactionAllowed: true,
}),
createParityCase({
name: "dmPolicy=pairing unauthorized",
dmPolicy: "pairing",
isSenderAllowed: () => false,
expectedDecision: "pairing",
expectedReactionAllowed: false,
}),
createParityCase({
name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list",
isGroup: true,
dmPolicy: "pairing",
allowFrom: ["owner"],
groupAllowFrom: ["group-owner"],
storeAllowFrom: ["paired-user"],
isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"),
expectedDecision: "block",
expectedReactionAllowed: false,
}),
].map((testCase) => ({
channel,
testCase,
})),
),
)(
"keeps message/reaction policy parity table across channels: [$channel] $testCase.name",
({ channel, testCase }) => {
expectParityCase(channel, testCase);
},
);
const decisionCases: DecisionCase[] = [
{
name: "blocks groups when group allowlist is empty",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)",
},
},
{
name: "allows groups when group policy is open",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "open",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
},
expected: {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=open",
},
},
{
name: "blocks DM allowlist mode when allowlist is empty",
input: {
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: "dmPolicy=allowlist (not allowlisted)",
},
},
{
name: "uses pairing flow when DM sender is not allowlisted",
input: {
isGroup: false,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
},
expected: {
decision: "pairing",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
reason: "dmPolicy=pairing (not allowlisted)",
},
},
{
name: "allows DM sender when allowlisted",
input: {
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => true,
},
expected: {
decision: "allow",
},
},
{
name: "blocks group allowlist mode when sender/group is not allowlisted",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: ["group:abc"],
isSenderAllowed: () => false,
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
reason: "groupPolicy=allowlist (not allowlisted)",
},
},
];
it.each(
channels.flatMap((channel) =>
decisionCases.map((testCase) => ({
channel,
testCase,
})),
),
)("[$channel] $testCase.name", ({ testCase }) => {
const decision = resolveDmGroupAccessDecision(testCase.input);
if ("reasonCode" in testCase.expected && "reason" in testCase.expected) {
expect(decision).toEqual(testCase.expected);
return;
}
expect(decision).toMatchObject(testCase.expected);
});
});

View File

@@ -1,9 +1,13 @@
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js";
import { resolveGroupAllowFromSources } from "../channels/allow-from.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import { resolveDmAllowAuditState } from "../channels/message-access/dm-allow-state.js";
import {
readChannelIngressStoreAllowFromForDmPolicy,
resolveChannelIngressEffectiveAllowFromLists,
} from "../channels/message-access/runtime.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { GroupPolicy } from "../config/types.base.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
export function resolvePinnedMainDmOwnerFromAllowlist(params: {
@@ -28,6 +32,7 @@ export function resolvePinnedMainDmOwnerFromAllowlist(params: {
return normalizedOwners.length === 1 ? normalizedOwners[0] : null;
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
@@ -38,25 +43,7 @@ export function resolveEffectiveAllowFromLists(params: {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined;
const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined;
const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined;
const effectiveAllowFrom = normalizeStringEntries(
mergeDmAllowFromSources({
allowFrom,
storeAllowFrom,
dmPolicy: params.dmPolicy ?? undefined,
}),
);
// Group auth is explicit (groupAllowFrom fallback allowFrom). Pairing store is DM-only.
const effectiveGroupAllowFrom = normalizeStringEntries(
resolveGroupAllowFromSources({
allowFrom,
groupAllowFrom,
fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined,
}),
);
return { effectiveAllowFrom, effectiveGroupAllowFrom };
return resolveChannelIngressEffectiveAllowFromLists(params);
}
export type DmGroupAccessDecision = "allow" | "block" | "pairing";
@@ -73,35 +60,37 @@ export const DM_GROUP_ACCESS_REASON = {
} as const;
export type DmGroupAccessReasonCode =
(typeof DM_GROUP_ACCESS_REASON)[keyof typeof DM_GROUP_ACCESS_REASON];
type DmGroupAccessResult = {
decision: DmGroupAccessDecision;
reasonCode: DmGroupAccessReasonCode;
reason: string;
};
const dmGroupAccess = (
decision: DmGroupAccessDecision,
reasonCode: DmGroupAccessReasonCode,
reason: string,
): DmGroupAccessResult => ({ decision, reasonCode, reason });
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveOpenDmAllowlistAccess(params: {
effectiveAllowFrom: Array<string | number>;
isSenderAllowed: (allowFrom: string[]) => boolean;
}): {
decision: Extract<DmGroupAccessDecision, "allow" | "block">;
reasonCode: DmGroupAccessReasonCode;
reason: string;
} {
}): DmGroupAccessResult {
const effectiveAllowFrom = normalizeStringEntries(params.effectiveAllowFrom);
if (effectiveAllowFrom.includes("*")) {
return {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN,
reason: "dmPolicy=open",
};
}
if (params.isSenderAllowed(effectiveAllowFrom)) {
return {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
reason: "dmPolicy=open (allowlisted)",
};
}
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: "dmPolicy=open (not allowlisted)",
};
return effectiveAllowFrom.includes("*")
? dmGroupAccess("allow", DM_GROUP_ACCESS_REASON.DM_POLICY_OPEN, "dmPolicy=open")
: params.isSenderAllowed(effectiveAllowFrom)
? dmGroupAccess(
"allow",
DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
"dmPolicy=open (allowlisted)",
)
: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
"dmPolicy=open (not allowlisted)",
);
}
type DmGroupAccessInputParams = {
@@ -115,6 +104,33 @@ type DmGroupAccessInputParams = {
isSenderAllowed: (allowFrom: string[]) => boolean;
};
const GROUP_ACCESS_RESULT: Record<
Exclude<ReturnType<typeof evaluateMatchedGroupAccessForPolicy>["reason"], "allowed">,
DmGroupAccessResult
> = {
disabled: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED,
"groupPolicy=disabled",
),
empty_allowlist: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
"groupPolicy=allowlist (empty allowlist)",
),
missing_match_input: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
"groupPolicy=allowlist (not allowlisted)",
),
not_allowlisted: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
"groupPolicy=allowlist (not allowlisted)",
),
};
/** @deprecated Use `resolveChannelMessageIngress` or `readChannelIngressStoreAllowFromForDmPolicy` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function readStoreAllowFromForDmPolicy(params: {
provider: ChannelId;
accountId: string;
@@ -122,20 +138,10 @@ export async function readStoreAllowFromForDmPolicy(params: {
shouldRead?: boolean | null;
readStore?: (provider: ChannelId, accountId: string) => Promise<string[]>;
}): Promise<string[]> {
if (
params.shouldRead === false ||
params.dmPolicy === "allowlist" ||
params.dmPolicy === "open"
) {
return [];
}
const readStore =
params.readStore ??
((provider: ChannelId, accountId: string) =>
readChannelAllowFromStore(provider, process.env, accountId));
return await readStore(params.provider, params.accountId).catch(() => []);
return await readChannelIngressStoreAllowFromForDmPolicy(params);
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveDmGroupAccessDecision(params: {
isGroup: boolean;
dmPolicy?: string | null;
@@ -143,11 +149,7 @@ export function resolveDmGroupAccessDecision(params: {
effectiveAllowFrom: Array<string | number>;
effectiveGroupAllowFrom: Array<string | number>;
isSenderAllowed: (allowFrom: string[]) => boolean;
}): {
decision: DmGroupAccessDecision;
reasonCode: DmGroupAccessReasonCode;
reason: string;
} {
}): DmGroupAccessResult {
const dmPolicy = params.dmPolicy ?? "pairing";
const groupPolicy: GroupPolicy =
params.groupPolicy === "open" || params.groupPolicy === "disabled"
@@ -162,44 +164,30 @@ export function resolveDmGroupAccessDecision(params: {
allowlistConfigured: effectiveGroupAllowFrom.length > 0,
allowlistMatched: params.isSenderAllowed(effectiveGroupAllowFrom),
});
if (!groupAccess.allowed) {
if (groupAccess.reason === "disabled") {
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED,
reason: "groupPolicy=disabled",
};
}
if (groupAccess.reason === "empty_allowlist") {
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)",
};
}
if (groupAccess.reason === "not_allowlisted") {
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
reason: "groupPolicy=allowlist (not allowlisted)",
};
}
if (groupAccess.allowed) {
return dmGroupAccess(
"allow",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
`groupPolicy=${groupPolicy}`,
);
}
switch (groupAccess.reason) {
case "disabled":
case "empty_allowlist":
case "missing_match_input":
case "not_allowlisted":
return GROUP_ACCESS_RESULT[groupAccess.reason];
case "allowed":
return dmGroupAccess(
"allow",
DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
`groupPolicy=${groupPolicy}`,
);
}
return {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: `groupPolicy=${groupPolicy}`,
};
}
if (dmPolicy === "disabled") {
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED,
reason: "dmPolicy=disabled",
};
return dmGroupAccess("block", DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED, "dmPolicy=disabled");
}
if (dmPolicy === "open") {
return resolveOpenDmAllowlistAccess({
@@ -207,27 +195,26 @@ export function resolveDmGroupAccessDecision(params: {
isSenderAllowed: params.isSenderAllowed,
});
}
if (params.isSenderAllowed(effectiveAllowFrom)) {
return {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
reason: `dmPolicy=${dmPolicy} (allowlisted)`,
};
}
if (dmPolicy === "pairing") {
return {
decision: "pairing",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
reason: "dmPolicy=pairing (not allowlisted)",
};
}
return {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: `dmPolicy=${dmPolicy} (not allowlisted)`,
};
return params.isSenderAllowed(effectiveAllowFrom)
? dmGroupAccess(
"allow",
DM_GROUP_ACCESS_REASON.DM_POLICY_ALLOWLISTED,
`dmPolicy=${dmPolicy} (allowlisted)`,
)
: dmPolicy === "pairing"
? dmGroupAccess(
"pairing",
DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
"dmPolicy=pairing (not allowlisted)",
)
: dmGroupAccess(
"block",
DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
`dmPolicy=${dmPolicy} (not allowlisted)`,
);
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): {
decision: DmGroupAccessDecision;
reasonCode: DmGroupAccessReasonCode;
@@ -257,6 +244,7 @@ export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams):
};
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export function resolveDmGroupAccessWithCommandGate(
params: DmGroupAccessInputParams & {
command?: {
@@ -298,19 +286,17 @@ export function resolveDmGroupAccessWithCommandGate(
const commandGroupAllowFrom = params.isGroup
? configuredGroupAllowFrom
: access.effectiveGroupAllowFrom;
const ownerAllowedForCommands = params.isSenderAllowed(commandDmAllowFrom);
const groupAllowedForCommands = params.isSenderAllowed(commandGroupAllowFrom);
const commandGate = params.command
? resolveControlCommandGate({
useAccessGroups: params.command.useAccessGroups,
authorizers: [
{
configured: commandDmAllowFrom.length > 0,
allowed: ownerAllowedForCommands,
allowed: params.isSenderAllowed(commandDmAllowFrom),
},
{
configured: commandGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
allowed: params.isSenderAllowed(commandGroupAllowFrom),
},
],
allowTextCommands: params.command.allowTextCommands,
@@ -325,6 +311,7 @@ export function resolveDmGroupAccessWithCommandGate(
};
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export async function resolveDmAllowState(params: {
provider: ChannelId;
accountId: string;
@@ -338,31 +325,5 @@ export async function resolveDmAllowState(params: {
allowCount: number;
isMultiUserDm: boolean;
}> {
const configAllowFrom = normalizeStringEntries(
Array.isArray(params.allowFrom) ? params.allowFrom : undefined,
);
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: params.provider,
accountId: params.accountId,
dmPolicy: params.dmPolicy,
readStore: params.readStore,
});
const normalizeEntry = params.normalizeEntry ?? ((value: string) => value);
const normalizedCfg = configAllowFrom
.filter((value) => value !== "*")
.map((value) => normalizeEntry(value))
.map((value) => value.trim())
.filter(Boolean);
const normalizedStore = storeAllowFrom
.map((value) => normalizeEntry(value))
.map((value) => value.trim())
.filter(Boolean);
const allowCount = new Set([...normalizedCfg, ...normalizedStore]).size;
return {
configAllowFrom,
hasWildcard,
allowCount,
isMultiUserDm: hasWildcard || allowCount > 1,
};
return await resolveDmAllowAuditState(params);
}