mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user