mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 05:20:45 +08:00
feat: add unified IM access policies
This commit is contained in:
parent
075d2713c1
commit
840e5aa857
89 changed files with 5573 additions and 1676 deletions
65
test/channels/shared/command-permission.test.mjs
Normal file
65
test/channels/shared/command-permission.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue