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

@ -0,0 +1,210 @@
// Shared by the Host and settings UI; keep this module browser-compatible.
export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
const POLICY_KEYS = ['direct', 'group'];
const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
const ALLOWLIST_KEYS = ['users'];
const USER_KEYS = ['id', 'canExecuteCommands'];
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
function invalidAccessPolicy(message) {
const error = new TypeError(message);
error.code = 'access-policy-invalid';
return error;
}
function hasExactKeys(input, keys) {
return input && typeof input === 'object' && !Array.isArray(input)
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
&& Reflect.ownKeys(input).length === keys.length
&& keys.every((key) => Object.hasOwn(input, key));
}
/** Normalize one opaque channel identity without interpreting its contents. */
export function normalizeAccessPolicyUserId(value) {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
value = String(value);
} else if (typeof value === 'bigint') {
value = String(value);
}
if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
const id = value.trim();
if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
}
return id;
}
function validateAccessPolicyUser(input) {
if (!hasExactKeys(input, USER_KEYS)) {
throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
}
if (typeof input.canExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('命令权限必须是布尔值。');
}
return Object.freeze({
id: normalizeAccessPolicyUserId(input.id),
canExecuteCommands: input.canExecuteCommands,
});
}
function validateUsers(input, { listMessage, duplicateMessage }) {
if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
const users = input.map(validateAccessPolicyUser);
if (new Set(users.map(({ id }) => id)).size !== users.length) {
throw invalidAccessPolicy(duplicateMessage);
}
return Object.freeze(users);
}
function validateOpenSettings(input) {
if (!hasExactKeys(input, OPEN_KEYS)) {
throw invalidAccessPolicy('开放模式设置必须完整。');
}
if (typeof input.defaultCanExecuteCommands !== 'boolean') {
throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
}
return Object.freeze({
defaultCanExecuteCommands: input.defaultCanExecuteCommands,
commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
listMessage: '开放模式命令权限覆盖用户必须是数组。',
duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
}),
});
}
function validateAllowlistSettings(input) {
if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
throw invalidAccessPolicy('白名单模式设置必须完整。');
}
return Object.freeze({
users: validateUsers(input.users, {
listMessage: '白名单模式用户必须是数组。',
duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
}),
});
}
function validateAccessPolicyScope(input) {
if (!hasExactKeys(input, SCOPE_KEYS)) {
throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
}
if (!ACCESS_POLICY_MODES.includes(input.mode)) {
throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
}
return Object.freeze({
mode: input.mode,
open: validateOpenSettings(input.open),
allowlist: validateAllowlistSettings(input.allowlist),
});
}
/** Validate one canonical direct + group atomic save. */
export function validateAccessPolicy(input) {
if (!hasExactKeys(input, POLICY_KEYS)) {
throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
}
return Object.freeze({
direct: validateAccessPolicyScope(input.direct),
group: validateAccessPolicyScope(input.group),
});
}
/** Normalize canonical persisted/runtime data; damaged settings fail closed. */
export function normalizeAccessPolicy(input) {
try {
return validateAccessPolicy(input);
} catch {
return null;
}
}
/** Small canonical constructor used by channel initialization. */
export function createAccessPolicyScope(options) {
return validateAccessPolicyScope(options === undefined ? {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: { users: [] },
} : options);
}
const DENY_SCOPE = createAccessPolicyScope();
export const DEFAULT_ACCESS_POLICY = Object.freeze({
direct: DENY_SCOPE,
group: DENY_SCOPE,
});
export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
export function createAccessPolicy({
direct = DEFAULT_ACCESS_POLICY.direct,
group = DEFAULT_ACCESS_POLICY.group,
} = {}) {
return validateAccessPolicy({ direct, group });
}
const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
/**
* Decide access for one ordinary message or one already-recognized command.
* `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
* Privileged/owner bypass is intentionally handled by the inbound adapter.
*/
export function evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand = false,
equals = (left, right) => left === right,
} = {}) {
const normalizedPolicy = normalizeAccessPolicy(policy);
if (!normalizedPolicy) return POLICY_UNAVAILABLE;
if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
|| typeof isCommand !== 'boolean' || typeof equals !== 'function') {
return INVALID_CONTEXT;
}
const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
.flatMap((candidate) => {
try {
return [normalizeAccessPolicyUserId(candidate)];
} catch {
return [];
}
});
if (candidates.length === 0) return SENDER_UNAVAILABLE;
const scope = normalizedPolicy[conversationType];
const users = scope.mode === 'open'
? scope.open.commandPermissionOverrides
: scope.allowlist.users;
let matchedUsers;
try {
matchedUsers = users.filter((user) => (
candidates.some((candidate) => equals(candidate, user.id) === true)
));
} catch {
return INVALID_CONTEXT;
}
if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
if (isCommand) {
const canExecuteCommands = scope.mode === 'open'
? (matchedUsers.length > 0
? matchedUsers.every((user) => user.canExecuteCommands)
: scope.open.defaultCanExecuteCommands)
: matchedUsers.every((user) => user.canExecuteCommands);
if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
}
return ALLOWED;
}

View file

@ -13,6 +13,10 @@ import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import {
normalizeAccessPolicy,
validateAccessPolicy,
} from './access-policy.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -131,6 +135,26 @@ function normalizeDeliveryTargets(value) {
return deliveryTargets;
}
function normalizeAccessPolicies(value, workspaces) {
const accessPolicies = Object.create(null);
if (value === undefined) return accessPolicies;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
// Preserve the distinction between a missing policy (eligible for startup
// initialization) and damaged persisted data (fail closed).
for (const botId of Object.keys(workspaces)) accessPolicies[botId] = null;
return accessPolicies;
}
for (const [botId, policy] of Object.entries(value)) {
try {
botIdOf(botId);
accessPolicies[botId] = normalizeAccessPolicy(policy);
} catch {
// An invalid key cannot identify a bot, so it is isolated and ignored.
}
}
return accessPolicies;
}
function normalizeDocument(value) {
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
@ -168,15 +192,52 @@ function normalizeDocument(value) {
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
if (!deliveryTargets) return null;
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
const version = value.accessPolicies === undefined ? value.version : 2;
return {
version: value.version,
// A v1 file cannot be emitted with this optional v2 section. If one is
// recovered from an interrupted/manual edit, retain it on the next write.
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
};
}
function storedDocument({
version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}) {
const document = { version, workspaces };
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
document.accessPolicies = accessPolicies;
}
return document;
}
async function writeStoredDocument(path, document) {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, path);
}
export async function validateWorkspacePath(value) {
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
const error = new Error('工作区必须是绝对路径。');
@ -208,6 +269,7 @@ export class BotWorkspaceStore {
#agentPresets = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
#accessPolicies = Object.create(null);
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -233,6 +295,7 @@ export class BotWorkspaceStore {
this.#agentPresets = normalized.agentPresets;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
this.#accessPolicies = normalized.accessPolicies;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#version = 1;
@ -240,6 +303,7 @@ export class BotWorkspaceStore {
this.#agentPresets = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
this.#accessPolicies = Object.create(null);
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -278,6 +342,13 @@ export class BotWorkspaceStore {
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
accessPolicyFor(botId) {
const id = botIdOf(botId);
return this.has(id) && Object.hasOwn(this.#accessPolicies, id)
? this.#accessPolicies[id]
: null;
}
listDeliveryTargets(botId) {
const id = botIdOf(botId);
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
@ -373,28 +444,53 @@ export class BotWorkspaceStore {
}
}
async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
async ensure(botId, {
workspace = this.#defaultWorkspace,
defaultAgentPreset,
initialAccessPolicy,
} = {}) {
const id = botIdOf(botId);
const initialWorkspace = resolve(workspace);
return this.#enqueue(id, async () => {
if (!this.#workspaces[id]) {
const agentPreset = validateAgentPresetId(defaultAgentPreset);
const createsBot = !this.#workspaces[id];
const initializesAccessPolicy = initialAccessPolicy !== undefined
&& !Object.hasOwn(this.#accessPolicies, id);
if (createsBot || initializesAccessPolicy) {
const accessPolicy = initializesAccessPolicy
? validateAccessPolicy(initialAccessPolicy)
: undefined;
const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
const previousAgentPreset = this.#agentPresets[id];
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
const nextAccessPolicies = initializesAccessPolicy
? { ...this.#accessPolicies, [id]: accessPolicy }
: this.#accessPolicies;
if (createsBot) {
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
}
const nextVersion = initializesAccessPolicy ? 2 : this.#version;
try {
await this.#persist();
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
nextVersion,
nextAccessPolicies,
);
} catch (error) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (createsBot) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
}
throw error;
}
this.#accessPolicies = nextAccessPolicies;
this.#version = nextVersion;
} else if (!this.#generations.has(id)) {
this.#generations.set(id, this.#freshGeneration());
}
@ -487,6 +583,30 @@ export class BotWorkspaceStore {
});
}
async setAccessPolicy(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
const policy = validateAccessPolicy(value);
return this.#enqueue(id, async () => {
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const next = { ...this.#accessPolicies, [id]: policy };
// Inbound messages keep the previous committed snapshot until rename succeeds.
await this.#persist(
this.#contextEnhancement,
this.#deliveryTargets,
2,
next,
);
this.#accessPolicies = next;
this.#version = 2;
return policy;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -646,6 +766,7 @@ export class BotWorkspaceStore {
...Object.keys(this.#agentPresets),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
...Object.keys(this.#accessPolicies),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -663,6 +784,7 @@ export class BotWorkspaceStore {
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
accessPolicy: this.accessPolicyFor(bot.botId),
}
: bot),
};
@ -694,12 +816,14 @@ export class BotWorkspaceStore {
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
delete this.#accessPolicies[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -733,24 +857,16 @@ export class BotWorkspaceStore {
contextEnhancement = this.#contextEnhancement,
deliveryTargets = this.#deliveryTargets,
version = this.#version,
accessPolicies = this.#accessPolicies,
) {
const document = { version, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, this.#path);
await writeStoredDocument(this.#path, storedDocument({
version,
workspaces: this.#workspaces,
agentPresets: this.#agentPresets,
contextEnhancement,
deliveryTargets,
accessPolicies,
}));
this.#dirtyRemovals.clear();
}
@ -758,7 +874,8 @@ export class BotWorkspaceStore {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0) {
|| Object.keys(this.#deliveryTargets).length > 0
|| Object.keys(this.#accessPolicies).length > 0) {
await this.#persist();
return;
}
@ -1280,6 +1397,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return result;
});
};
const updateAccessPolicy = (botId, value, projectStatus) => {
const incarnation = workspaces.incarnationFor(botId);
const policy = validateAccessPolicy(value);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
const decorated = workspaces.decorateStatus(snapshot);
const updated = {
...decorated,
bots: decorated.bots.map((bot) => bot?.botId === botId
? { ...bot, accessPolicy: policy } : bot),
...(catalog ? { agentPresetCatalog: catalog } : {}),
};
// Prepare the complete channel-specific response before commit. Failed
// projections and disk writes must leave the live policy unchanged.
const result = projectStatus ? await projectStatus(updated) : updated;
await workspaces.setAccessPolicy(botId, policy, { incarnation });
return result;
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
// Fence the old runtime without changing the durable mapping. A crash
// before the controller removes its config therefore keeps the bot's
@ -1320,6 +1462,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
if (property === 'updateAccessPolicy') return updateAccessPolicy;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,32 @@
import { isBatchInputCommand } from './batch-input.mjs';
import { isCompactCommand } from './compact-command.mjs';
import { isControlCommand } from './control-command.mjs';
import { isHistoryCommand } from './history-command.mjs';
import { isModelCommand } from './model-command.mjs';
import { isPresetCommand } from './preset-command.mjs';
import { isWorkspaceCommand } from './workspace-command.mjs';
const SIMPLE_TEXT_COMMANDS = new Set(['/help', '/status', '/new']);
/**
* Match only commands that the shared bridges already execute locally.
* Unknown slash-prefixed text remains an ordinary prompt.
*/
export function isSharedLocalCommand(text, {
hasImages = false,
hasFiles = false,
} = {}) {
if (typeof text !== 'string') return false;
const command = text.trim();
if (!command) return false;
if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
if (!hasFiles && (
isControlCommand(command)
|| isModelCommand(command)
|| isPresetCommand(command)
)) return true;
if (hasImages || hasFiles) return false;
return SIMPLE_TEXT_COMMANDS.has(command.toLowerCase())
|| isWorkspaceCommand(command)
|| isCompactCommand(command);
}

View file

@ -70,9 +70,8 @@ function compactErrorMessage(error) {
* Unknown input returns null so the caller may continue ordinary message routing.
*/
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
if (typeof text !== 'string') return null;
if (!isCompactCommand(text)) return null;
const match = COMPACT_COMMAND.exec(text.trim());
if (!match) return null;
if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
if (typeof state?.sessionFor !== 'function') {
return commandResult(t('当前机器人没有可用的会话状态。'));
@ -94,3 +93,7 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
return commandResult(compactErrorMessage(error));
}
}
export function isCompactCommand(text) {
return typeof text === 'string' && COMPACT_COMMAND.test(text.trim());
}

View file

@ -185,4 +185,6 @@ export default {
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
'你可以发送普通消息,但没有执行命令的权限。':
'You can send regular messages, but you do not have permission to run commands.',
};

View file

@ -0,0 +1,43 @@
import { evaluateAccessPolicy } from './access-policy.mjs';
import { isSharedLocalCommand } from './command-permission.mjs';
export const COMMAND_PERMISSION_DENIED_MESSAGE = '你可以发送普通消息,但没有执行命令的权限。';
const POLICY_NOT_CONFIGURED = Object.freeze({ allowed: true, reason: 'policy-not-configured' });
const PRIVILEGED_SENDER = Object.freeze({ allowed: true, reason: 'privileged-sender' });
/**
* Read one committed policy snapshot and decide a single inbound event.
* A missing provider is kept backward-compatible for direct bridge fixtures;
* production always injects a provider, whose missing/damaged value fails closed.
*/
export function evaluateInboundAccess(accessPolicy, {
conversationType,
senderIds,
text = '',
hasImages = false,
hasFiles = false,
isCommand = isSharedLocalCommand(text, { hasImages, hasFiles }),
} = {}) {
if (!accessPolicy) return POLICY_NOT_CONFIGURED;
try {
if (typeof accessPolicy.isPrivileged === 'function'
&& accessPolicy.isPrivileged(senderIds, conversationType) === true) {
return PRIVILEGED_SENDER;
}
} catch {
// A broken privilege lookup must not bypass the persisted policy.
}
let policy = null;
try {
policy = accessPolicy.getSettings();
} catch {
// Provider failures are equivalent to an unavailable persisted policy.
}
return evaluateAccessPolicy(policy, {
conversationType,
senderIds,
isCommand,
...(typeof accessPolicy.equals === 'function' ? { equals: accessPolicy.equals } : {}),
});
}

View file

@ -1,4 +1,8 @@
import { t } from './i18n.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
evaluateInboundAccess,
} from './inbound-access.mjs';
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
@ -129,6 +133,7 @@ export class TextHarnessBridge {
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#status;
#logger;
#replyTimeoutMs;
@ -149,6 +154,7 @@ export class TextHarnessBridge {
harness,
state,
contextEnhancement,
accessPolicy,
status = createTextBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -162,6 +168,7 @@ export class TextHarnessBridge {
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -176,7 +183,7 @@ export class TextHarnessBridge {
return structuredClone(this.#status);
}
accept(message, { contextSnapshot } = {}) {
accept(message, { contextSnapshot, accessDecision } = {}) {
if (this.#signal?.aborted) return Promise.resolve();
const conversationId = cleanText(message?.conversationId);
const kind = message?.kind === 'group' ? 'group' : 'direct';
@ -187,6 +194,40 @@ export class TextHarnessBridge {
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
return Promise.resolve();
}
// Preserve the channel trigger boundary. Access policy never turns an
// unaddressed group message into a denial reply.
if (kind === 'group' && normalized.addressed !== true) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(normalized, messageId, null);
}
if (this.#accessPolicy || accessDecision) {
const hasImages = hasInboundImages(normalized);
const hasFiles = hasInboundFiles(normalized);
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
conversationType: kind,
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
text: normalized.content,
hasImages,
hasFiles,
});
if (!decision.allowed) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
// Mark policy denials so a webhook replay cannot repeat local work or
// a command-permission notice.
this.#acceptedMessageIds.set(messageId, null);
return this.#finishLocalMessage(
normalized,
messageId,
decision.reason === 'command-not-allowed'
? t(COMMAND_PERMISSION_DENIED_MESSAGE)
: null,
{ recordReceived: decision.reason === 'command-not-allowed' },
);
}
}
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
: contextSnapshot);
@ -350,13 +391,15 @@ export class TextHarnessBridge {
return this.#enqueueMessage(normalized, messageId, senderId, key);
}
#finishLocalMessage(message, messageId, reply) {
#finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
let task;
task = (async () => {
if (this.#state.hasSeen(messageId)) return;
await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
if (recordReceived) {
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
}
if (reply) await this.#bot.sendText(message.replyTarget, reply);
this.#status.lastError = null;
})().catch(async (error) => {

View file

@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
}
export async function runWorkspaceCommand(text, harness, conversationKey) {
if (typeof text !== 'string') return null;
if (!isWorkspaceCommand(text)) return null;
const command = text.trim();
if (SESSION_BIND_PREFIX.test(command)) {
return runSessionBindCommand(command, harness, conversationKey);
@ -385,3 +385,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
throw error;
}
}
export function isWorkspaceCommand(text) {
if (typeof text !== 'string') return false;
const command = text.trim();
return SESSION_BIND_PREFIX.test(command)
|| SESSION_LIST_COMMAND.test(command)
|| WORKSPACE_LIST_COMMAND.test(command)
|| WORKSPACE_COMMAND.test(command);
}