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,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSharedLocalCommand } from '../../../src/channels/shared/command-permission.mjs';
import { evaluateInboundAccess } from '../../../src/channels/shared/inbound-access.mjs';
test('isSharedLocalCommand matches existing local command families', () => {
for (const command of [
'/help', '/status', '/new', '/version', '/stop', '/steer more',
'/batch', '/send', '/cancel', '/history 3', '/workspace /tmp',
'/workspacelist', '/sessionlist', '/sessions /tmp', '/session 2',
'/compact', '/models', '/model 2', '/reasonings', '/reasoning high',
'/presetlist', '/preset default',
]) {
assert.equal(isSharedLocalCommand(command), true, command);
}
});
test('isSharedLocalCommand leaves unknown and channel-specific slash text as ordinary prompts', () => {
for (const text of [
'/foo', '/help me', 'hello', '/', '',
'/menu', '/repair verify', '/watch session-id', '/unwatch session-id',
'/watchlist', '/archived off',
]) {
assert.equal(isSharedLocalCommand(text), false, text);
}
});
test('isSharedLocalCommand follows current media command routing', () => {
assert.equal(isSharedLocalCommand('/history', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/batch', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/status', { hasImages: true }), false);
assert.equal(isSharedLocalCommand('/workspace /tmp', { hasFiles: true }), false);
assert.equal(isSharedLocalCommand('/stop', { hasImages: true }), true);
assert.equal(isSharedLocalCommand('/stop', { hasFiles: true }), false);
});
test('evaluateInboundAccess always preserves an original owner privilege', () => {
const deniedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const accessPolicy = {
getSettings: () => deniedPolicy,
isPrivileged: (senderIds) => senderIds === 'owner-id',
};
assert.deepEqual(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'owner-id',
text: '/status',
}), { allowed: true, reason: 'privileged-sender' });
assert.equal(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'another-user',
text: '/status',
}).allowed, false);
});

View file

@ -7,6 +7,7 @@ import manifest from '../../../package.json' with { type: 'json' };
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
@ -88,6 +89,21 @@ function message(messageId, content, overrides = {}) {
};
}
function accessPolicy({ canExecuteCommands = false } = {}) {
return {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: 'actor-a', canExecuteCommands }] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
}
function questionInteraction({
id = 'question-one',
sessionId = 'session-one',
@ -277,6 +293,84 @@ test('shared status reactions replace processing with success without joining th
]);
});
test('all four shared text channels enforce fail-closed live access before side effects', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture();
const sent = [];
const asks = [];
let imageLoads = 0;
let sessionClears = 0;
let policyReadFails = true;
let settings = null;
const originalClearSession = fixture.state.clearSession.bind(fixture.state);
fixture.state.clearSession = async (...args) => {
sessionClears += 1;
return originalClearSession(...args);
};
const bridge = new Bridge({
accessPolicy: {
getSettings() {
if (policyReadFails) throw new Error('private policy read detail');
return settings;
},
isPrivileged: (senderIds) => senderIds.includes('owner-a'),
},
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
harness: {
createSession: async () => `session-access-${name}`,
sessionExists: async () => true,
ask: async (_sessionId, content) => {
asks.push(content);
return `${name} allowed reply`;
},
},
});
await bridge.accept(message(`access-blocked-${name}`, 'blocked attachment', {
images: [{
mediaType: 'image/png',
load: async () => {
imageLoads += 1;
return Buffer.from('must not load');
},
}],
}));
assert.equal(imageLoads, 0, `${name} authorizes before downloading attachments`);
assert.deepEqual(asks, [], `${name} fail-closed denial never reaches Harness`);
assert.equal(fixture.seen.has(`access-blocked-${name}`), true,
`${name} records a denial for replay suppression`);
await bridge.accept(message(`access-owner-${name}`, '/help', { senderId: 'owner-a' }));
assert.match(sent.at(-1), /\/help/, `${name} owner bypasses a failed policy read`);
assert.deepEqual(asks, [], `${name} owner command remains local`);
policyReadFails = false;
settings = accessPolicy();
await bridge.accept(message(`access-blocked-${name}`, 'replayed after policy update'));
assert.deepEqual(asks, [], `${name} a denied replay cannot bypass the new policy`);
await bridge.accept(message(`access-ordinary-${name}`, 'allowed ordinary message'));
assert.equal(asks.length, 1, `${name} applies the live policy to a new event`);
assert.equal(sent.at(-1), `${name} allowed reply`, `${name} keeps the normal reply path`);
await bridge.accept(message(`access-command-denied-${name}`, '/new'));
assert.equal(asks.length, 1, `${name} denied command never reaches Harness`);
assert.equal(sessionClears, 0, `${name} denied command has no command side effect`);
assert.equal(sent.at(-1), COMMAND_PERMISSION_DENIED_MESSAGE, `${name} explains command denial`);
settings = accessPolicy({ canExecuteCommands: true });
await bridge.accept(message(`access-command-allowed-${name}`, '/new'));
assert.equal(sessionClears, 1, `${name} policy hot-update applies without rebuilding the bridge`);
assert.equal(asks.length, 1, `${name} allowed local command is not a model prompt`);
}
});
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
const fixture = stateFixture();
const controller = new AbortController();