command-gate: restore the /start filter and remove the fail-open admin check

Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-07-04 16:04:58 +03:00
parent aecad864e6
commit fcee39ea14
2 changed files with 86 additions and 14 deletions
+83
View File
@@ -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' });
});
});
+3 -14
View File
@@ -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);
}