mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 00:47:49 +00:00
refactor: centralize channel ingress access
This commit is contained in:
@@ -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>;
|
||||
|
||||
135
src/channels/message-access/allowlist.ts
Normal file
135
src/channels/message-access/allowlist.ts
Normal 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);
|
||||
}
|
||||
327
src/channels/message-access/decision.ts
Normal file
327
src/channels/message-access/decision.ts
Normal 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 });
|
||||
}
|
||||
45
src/channels/message-access/dm-allow-state.ts
Normal file
45
src/channels/message-access/dm-allow-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
src/channels/message-access/index.ts
Normal file
31
src/channels/message-access/index.ts
Normal 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";
|
||||
229
src/channels/message-access/message-access.test.ts
Normal file
229
src/channels/message-access/message-access.test.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
95
src/channels/message-access/runtime-access-groups.ts
Normal file
95
src/channels/message-access/runtime-access-groups.ts
Normal 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;
|
||||
}
|
||||
180
src/channels/message-access/runtime-identity.ts
Normal file
180
src/channels/message-access/runtime-identity.ts
Normal 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 };
|
||||
}
|
||||
368
src/channels/message-access/runtime-types.ts
Normal file
368
src/channels/message-access/runtime-types.ts
Normal 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;
|
||||
};
|
||||
722
src/channels/message-access/runtime.ts
Normal file
722
src/channels/message-access/runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
166
src/channels/message-access/sender-gates.ts
Normal file
166
src/channels/message-access/sender-gates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
396
src/channels/message-access/state.ts
Normal file
396
src/channels/message-access/state.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
369
src/channels/message-access/types.ts
Normal file
369
src/channels/message-access/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
61
src/plugin-sdk/access-groups.test.ts
Normal file
61
src/plugin-sdk/access-groups.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
1
src/plugin-sdk/channel-access-compat.ts
Normal file
1
src/plugin-sdk/channel-access-compat.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../security/dm-policy-shared.js";
|
||||
146
src/plugin-sdk/channel-ingress-runtime.test.ts
Normal file
146
src/plugin-sdk/channel-ingress-runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
src/plugin-sdk/channel-ingress-runtime.ts
Normal file
44
src/plugin-sdk/channel-ingress-runtime.ts
Normal 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";
|
||||
620
src/plugin-sdk/channel-ingress.ts
Normal file
620
src/plugin-sdk/channel-ingress.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export {
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveOpenDmAllowlistAccess,
|
||||
} from "../security/dm-policy-shared.js";
|
||||
} from "./channel-access-compat.js";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,7 @@ type RouteLike = {
|
||||
};
|
||||
|
||||
type RoutePeerLike = {
|
||||
kind: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";',
|
||||
|
||||
@@ -174,6 +174,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
||||
},
|
||||
turn: {
|
||||
run: runChannelTurn,
|
||||
runAssembled: dispatchAssembledChannelTurn,
|
||||
runResolved: runResolvedChannelTurn,
|
||||
buildContext: buildChannelTurnContext,
|
||||
runPrepared: runPreparedChannelTurn,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user