diff --git a/src/command-gate.test.ts b/src/command-gate.test.ts new file mode 100644 index 000000000..503034d57 --- /dev/null +++ b/src/command-gate.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for the host-side command gate — filtered commands are dropped + * before reaching the container, and admin commands are gated against + * the user_roles table. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { gateCommand } from './command-gate.js'; +import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js'; +import { createUser } from './modules/permissions/db/users.js'; +import { grantRole } from './modules/permissions/db/user-roles.js'; + +function now(): string { + return new Date().toISOString(); +} + +function seedAgentGroup(id: string): void { + createAgentGroup({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: now() }); +} + +function seedUser(id: string): void { + createUser({ id, kind: 'telegram', display_name: null, created_at: now() }); +} + +beforeEach(() => { + const db = initTestDb(); + runMigrations(db); + seedAgentGroup('ag-1'); + seedAgentGroup('ag-2'); +}); + +afterEach(() => { + closeDb(); +}); + +describe('filtered commands', () => { + it('drops /start before it reaches the container', () => { + expect(gateCommand('/start', 'telegram:1', 'ag-1')).toEqual({ action: 'filter' }); + }); + + it('drops /start regardless of sender', () => { + expect(gateCommand('/start', null, 'ag-1')).toEqual({ action: 'filter' }); + }); +}); + +describe('admin gating goes through roles', () => { + it('denies an admin command from a non-admin user', () => { + expect(gateCommand('/clear', 'telegram:nobody', 'ag-1')).toEqual({ action: 'deny', command: '/clear' }); + }); + + it('denies an admin command with no sender', () => { + expect(gateCommand('/clear', null, 'ag-1')).toEqual({ action: 'deny', command: '/clear' }); + }); + + it('allows an admin command from an owner', () => { + seedUser('telegram:owner'); + grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() }); + expect(gateCommand('/clear', 'telegram:owner', 'ag-1')).toEqual({ action: 'pass' }); + }); + + it('allows an admin command from a scoped admin of the group', () => { + seedUser('telegram:admin'); + grantRole({ + user_id: 'telegram:admin', + role: 'admin', + agent_group_id: 'ag-1', + granted_by: null, + granted_at: now(), + }); + expect(gateCommand('/clear', 'telegram:admin', 'ag-1')).toEqual({ action: 'pass' }); + expect(gateCommand('/clear', 'telegram:admin', 'ag-2')).toEqual({ action: 'deny', command: '/clear' }); + }); +}); + +describe('normal messages pass through', () => { + it('passes a plain message', () => { + expect(gateCommand('hello there', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' }); + }); + + it('passes an unknown slash command', () => { + expect(gateCommand('/whatever', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' }); + }); +}); diff --git a/src/command-gate.ts b/src/command-gate.ts index 4f600eb12..933c39277 100644 --- a/src/command-gate.ts +++ b/src/command-gate.ts @@ -7,11 +7,11 @@ * "Permission denied" response written directly to messages_out * - Normal messages: pass through unchanged */ -import { getDb, hasTable } from './db/connection.js'; +import { hasAdminPrivilege } from './modules/permissions/db/user-roles.js'; export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string }; -const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); +const FILTERED_COMMANDS = new Set(['/start', '/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']); /** @@ -48,16 +48,5 @@ export function gateCommand(content: string, userId: string | null, agentGroupId function isAdmin(userId: string | null, agentGroupId: string): boolean { if (!userId) return false; - if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all - const db = getDb(); - const row = db - .prepare( - `SELECT 1 FROM user_roles - WHERE user_id = ? - AND (role = 'owner' OR role = 'admin') - AND (agent_group_id IS NULL OR agent_group_id = ?) - LIMIT 1`, - ) - .get(userId, agentGroupId); - return row != null; + return hasAdminPrivilege(userId, agentGroupId); }