feat: add unified IM access policies

This commit is contained in:
xmanrui 2026-09-01 10:45:12 +08:00
parent 075d2713c1
commit 840e5aa857
89 changed files with 5573 additions and 1676 deletions

View file

@ -59,6 +59,11 @@ import {
setLastMessageFailure,
} from '../shared/message-failure.mjs';
import { beginStatusReaction } from '../shared/status-reaction.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from '../shared/inbound-access.mjs';
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -135,6 +140,17 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
/** Matches fast card commands that should not be queued behind a running task. */
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
if (hasImages || hasFiles || typeof text !== 'string') return false;
const command = text.trim();
return MENU_COMMAND.test(command)
|| REPAIR_COMMAND_PREFIX.test(command)
|| WATCH_COMMAND.test(command)
|| UNWATCH_COMMAND.test(command)
|| WATCHLIST_COMMAND.test(command)
|| ARCHIVED_COMMAND.test(command);
}
/** Canonical workspace/session help advertised by every bridge family. */
const WORKSPACE_HELP_LINES = [
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
@ -385,6 +401,7 @@ export class FeishuHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#queues = new Map();
#batchInputs = new BatchInputManager();
#pendingInteractions = new Map();
@ -451,6 +468,7 @@ export class FeishuHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status,
allowedSenderOpenIds = new Set(),
botId,
@ -488,6 +506,7 @@ export class FeishuHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#allowedSenderOpenIds = allowedSenderOpenIds;
this.#botId = nonEmptyString(botId);
@ -526,10 +545,10 @@ export class FeishuHarnessBridge {
if (this.#signal?.aborted) return Promise.resolve();
const messageId = nonEmptyString(event?.message?.message_id);
if (!messageId || isBotSender(event)) return Promise.resolve();
if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
if (!this.#accessPolicy && !isAllowedSender(event, this.#allowedSenderOpenIds)) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the legacy allowlist');
return Promise.resolve();
}
const addressed = this.#isAddressed(event);
@ -551,6 +570,28 @@ export class FeishuHarnessBridge {
return Promise.resolve();
}
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const hasImages = hasInboundImages(commandMessage);
const hasFiles = hasInboundFiles(commandMessage);
const conversationType = event.message.chat_type === 'p2p' ? 'direct'
: event.message.chat_type === 'group' ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: senderOpenId(event),
text: commandText,
hasImages,
hasFiles,
isCommand: isSharedLocalCommand(commandText, {
hasImages,
hasFiles,
}) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key)),
});
if (!access.allowed) {
this.#acceptedMessageIds.set(messageId, null);
return this.#finishAccessDecision(event, messageId, access);
}
if (event.message.chat_type === 'p2p') {
const chatId = nonEmptyString(event.message.chat_id);
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@ -558,11 +599,9 @@ export class FeishuHarnessBridge {
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
this.#contextEnhancement,
event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
conversationType,
));
const processingReaction = this.#beginReaction(messageId);
const commandMessage = extractInboundMessage(event, this.#client);
const commandText = nonEmptyString(commandMessage.content) ?? '';
const batchText = event.message.message_type === 'text'
? nonEmptyString(extractText(event)) ?? ''
: '';
@ -792,6 +831,38 @@ export class FeishuHarnessBridge {
return current;
}
#finishAccessDecision(event, messageId, access) {
let current;
current = Promise.resolve().then(async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
if (access.reason === 'command-not-allowed') {
this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1;
await this.#send(
event.message.chat_id,
t(COMMAND_PERMISSION_DENIED_MESSAGE),
{ replyTo: event.message.message_id },
);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
} else {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
}
this.#status.lastError = null;
}).catch((error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.warn?.('[dsh-feishu] failed to apply inbound access policy');
}).finally(() => {
this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(current);
});
this.#commandTasks.add(current);
return current;
}
#enqueueMessage(event, messageId, key, processingReaction, {
releaseMessageId = true,
alreadyRecorded = false,
@ -1490,10 +1561,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.operator?.operator_id?.user_id)
?? nonEmptyString(event?.open_id)
?? nonEmptyString(event?.user_id);
const operatorAllowed = operatorOpenId !== null
&& (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
if (!operatorAllowed) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
if (!operatorOpenId) return Promise.resolve();
if (!this.#accessPolicy
&& !this.#allowedSenderOpenIds.has('*')
&& !this.#allowedSenderOpenIds.has(operatorOpenId)) {
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed legacy sender');
return Promise.resolve();
}
const actionValue = callbackObject(event?.action?.value);
@ -1532,6 +1604,11 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.message_id);
const route = messageId ? this.#cardKeys.get(messageId) : null;
if (!route) {
// A route is also the trusted direct/group scope for the unified policy.
// Without it, fail closed instead of producing an unauthorised side effect.
if (this.#accessPolicy) return Promise.resolve();
// Legacy callers without a unified policy still receive the current
// expired-card guidance introduced by the upstream thread-reply fix.
// The card predates this process (the in-memory mapping resets on
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id)
@ -1542,6 +1619,21 @@ export class FeishuHarnessBridge {
}
return Promise.resolve();
}
const conversationType = route.key.startsWith('p2p:') ? 'direct'
: route.key.startsWith('group:') ? 'group' : null;
const access = evaluateInboundAccess(this.#accessPolicy, {
conversationType,
senderIds: operatorOpenId,
isCommand: true,
});
if (!access.allowed) {
if (access.reason === 'command-not-allowed') {
return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
.catch(() => undefined);
}
this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
return Promise.resolve();
}
// A used card is recent even if it was first created long ago.
this.#cardKeys.delete(messageId);
this.#cardKeys.set(messageId, route);

View file

@ -114,6 +114,7 @@ export class FeishuRuntime {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#replyTimeoutMs;
#connectTimeoutMs;
#requestTimeoutMs;
@ -143,6 +144,7 @@ export class FeishuRuntime {
harness,
state,
contextEnhancement,
accessPolicy,
repair,
replyTimeoutMs = 600000,
connectTimeoutMs = 15000,
@ -176,6 +178,7 @@ export class FeishuRuntime {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#repair = repair ?? null;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
@ -276,6 +279,7 @@ export class FeishuRuntime {
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
botId: this.#botId,