refactor(security): unify elevated mode handling across surfaces
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { initAuditLogger } from '../audit/index.js';
|
||||
import {
|
||||
ELEVATION_DISABLE_CONFIRM_TEXT,
|
||||
ELEVATION_ENABLE_CONFIRM_TEXT,
|
||||
ELEVATION_INVALID_DURATION_TEXT,
|
||||
ELEVATION_USAGE_TEXT,
|
||||
getElevationStatusMessage,
|
||||
getElevationWindow,
|
||||
parseElevationDurationToMs,
|
||||
setElevationFromInput,
|
||||
type ElevationStore,
|
||||
} from './elevation.js';
|
||||
|
||||
function createStore(initial?: Record<string, string>): ElevationStore {
|
||||
const values = new Map<string, string>(Object.entries(initial ?? {}));
|
||||
return {
|
||||
get: (key) => values.get(key),
|
||||
set: (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('security/elevation', () => {
|
||||
const audit = {
|
||||
securityElevationEnabled: vi.fn(),
|
||||
securityElevationDisabled: vi.fn(),
|
||||
securityElevationExpired: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
initAuditLogger(audit as unknown as Parameters<typeof initAuditLogger>[0]);
|
||||
});
|
||||
|
||||
it('parses elevation durations', () => {
|
||||
expect(parseElevationDurationToMs('30s')).toBe(30_000);
|
||||
expect(parseElevationDurationToMs('10m')).toBe(600_000);
|
||||
expect(parseElevationDurationToMs('2h')).toBe(7_200_000);
|
||||
expect(parseElevationDurationToMs('1d')).toBe(86_400_000);
|
||||
expect(parseElevationDurationToMs('bad')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns off status when no elevation is active', () => {
|
||||
const store = createStore();
|
||||
expect(getElevationStatusMessage(store)).toBe('Elevated mode: off');
|
||||
});
|
||||
|
||||
it('returns on status with remaining seconds and reason', () => {
|
||||
const store = createStore({
|
||||
'elevation.until_ms': String(20_000),
|
||||
'elevation.id': 'e1',
|
||||
'elevation.reason': 'maintenance',
|
||||
});
|
||||
const message = getElevationStatusMessage(store, { nowMs: 10_500, reasonSeparator: ' - ' });
|
||||
expect(message).toBe('Elevated mode: on (10s remaining) - maintenance');
|
||||
});
|
||||
|
||||
it('expires stale elevation and optionally reports expired status', () => {
|
||||
const store = createStore({
|
||||
'elevation.until_ms': String(5_000),
|
||||
'elevation.id': 'e1',
|
||||
'elevation.reason': 'test',
|
||||
});
|
||||
|
||||
expect(getElevationStatusMessage(store, { nowMs: 6_000 })).toBe('Elevated mode: off');
|
||||
expect(getElevationStatusMessage(store, { nowMs: 6_000, showExpiredSuffix: true })).toBe('Elevated mode: off');
|
||||
|
||||
const store2 = createStore({
|
||||
'elevation.until_ms': String(5_000),
|
||||
'elevation.id': 'e2',
|
||||
'elevation.reason': 'test2',
|
||||
});
|
||||
const message = getElevationStatusMessage(store2, {
|
||||
nowMs: 6_000,
|
||||
showExpiredSuffix: true,
|
||||
auditContext: { sessionId: 'ws:s1', channel: 'ws', sender: 'c1' },
|
||||
});
|
||||
expect(message).toBe('Elevated mode: off (expired)');
|
||||
expect(audit.securityElevationExpired).toHaveBeenCalledWith(expect.objectContaining({
|
||||
session_id: 'ws:s1',
|
||||
elevation_id: 'e2',
|
||||
until_ms: 5000,
|
||||
}));
|
||||
});
|
||||
|
||||
it('enables elevation from command input', () => {
|
||||
const store = createStore();
|
||||
const message = setElevationFromInput(store, '10m reason text --yes', {
|
||||
nowMs: 1_000,
|
||||
idGenerator: () => 'id-1',
|
||||
auditContext: { sessionId: 'ws:s1', channel: 'ws', sender: 'c1' },
|
||||
});
|
||||
expect(message).toBe('Elevated mode: on until 1970-01-01T00:10:01.000Z');
|
||||
expect(getElevationWindow(store, { nowMs: 1_001 }).window).toEqual({
|
||||
untilMs: 601000,
|
||||
id: 'id-1',
|
||||
reason: 'reason text',
|
||||
});
|
||||
expect(audit.securityElevationEnabled).toHaveBeenCalledWith(expect.objectContaining({
|
||||
session_id: 'ws:s1',
|
||||
elevation_id: 'id-1',
|
||||
ttl_ms: 600000,
|
||||
}));
|
||||
});
|
||||
|
||||
it('disables elevation from command input', () => {
|
||||
const store = createStore({
|
||||
'elevation.until_ms': String(20_000),
|
||||
'elevation.id': 'e1',
|
||||
'elevation.reason': 'cleanup',
|
||||
});
|
||||
const message = setElevationFromInput(store, 'off --yes', {
|
||||
auditContext: { sessionId: 'ws:s1', channel: 'ws', sender: 'c1' },
|
||||
});
|
||||
expect(message).toBe('Elevated mode: off');
|
||||
expect(getElevationWindow(store)).toEqual({ expired: false });
|
||||
expect(audit.securityElevationDisabled).toHaveBeenCalledWith(expect.objectContaining({
|
||||
session_id: 'ws:s1',
|
||||
elevation_id: 'e1',
|
||||
reason: 'cleanup',
|
||||
}));
|
||||
});
|
||||
|
||||
it('validates command usage and confirmation', () => {
|
||||
const store = createStore();
|
||||
expect(setElevationFromInput(store, '')).toBe(ELEVATION_USAGE_TEXT);
|
||||
expect(setElevationFromInput(store, 'off')).toBe(ELEVATION_DISABLE_CONFIRM_TEXT);
|
||||
expect(setElevationFromInput(store, '10m reason')).toBe(ELEVATION_ENABLE_CONFIRM_TEXT);
|
||||
expect(setElevationFromInput(store, '10x reason --yes')).toBe(ELEVATION_INVALID_DURATION_TEXT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
|
||||
export interface ElevationStore {
|
||||
get(key: string): string | undefined;
|
||||
set(key: string, value: string): void;
|
||||
delete(key: string): void;
|
||||
}
|
||||
|
||||
export interface ElevationAuditContext {
|
||||
sessionId: string;
|
||||
channel: string;
|
||||
sender: string;
|
||||
}
|
||||
|
||||
export interface ElevationWindow {
|
||||
untilMs: number;
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ElevationWindowResult {
|
||||
window?: ElevationWindow;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export interface ElevationStatusOptions {
|
||||
auditContext?: ElevationAuditContext;
|
||||
nowMs?: number;
|
||||
showExpiredSuffix?: boolean;
|
||||
reasonSeparator?: string;
|
||||
}
|
||||
|
||||
export interface SetElevationOptions {
|
||||
auditContext?: ElevationAuditContext;
|
||||
nowMs?: number;
|
||||
idGenerator?: () => string;
|
||||
}
|
||||
|
||||
const ELEVATION_UNTIL_KEY = 'elevation.until_ms';
|
||||
const ELEVATION_REASON_KEY = 'elevation.reason';
|
||||
const ELEVATION_ID_KEY = 'elevation.id';
|
||||
|
||||
export const ELEVATION_USAGE_TEXT = 'Usage: /elevate <duration> <reason...> --yes | /elevate off --yes';
|
||||
export const ELEVATION_DISABLE_CONFIRM_TEXT =
|
||||
'Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes';
|
||||
export const ELEVATION_ENABLE_CONFIRM_TEXT =
|
||||
'Refusing to enable elevation without explicit confirmation. Use: /elevate <duration> <reason...> --yes';
|
||||
export const ELEVATION_INVALID_DURATION_TEXT = 'Invalid duration. Use one of: 30s, 10m, 1h, 1d';
|
||||
|
||||
function clearElevation(store: ElevationStore): void {
|
||||
store.delete(ELEVATION_UNTIL_KEY);
|
||||
store.delete(ELEVATION_REASON_KEY);
|
||||
store.delete(ELEVATION_ID_KEY);
|
||||
}
|
||||
|
||||
function emitExpiredAudit(context: ElevationAuditContext | undefined, id: string, untilMs: number, reason?: string): void {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
auditLogger?.securityElevationExpired({
|
||||
session_id: context.sessionId,
|
||||
channel: context.channel,
|
||||
sender: context.sender,
|
||||
elevation_id: id,
|
||||
until_ms: untilMs,
|
||||
reason: reason || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseElevationDurationToMs(value: string): number | null {
|
||||
const m = value.match(/^(\d+)([smhd])$/i);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseInt(m[1], 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return null;
|
||||
}
|
||||
const unit = m[2].toLowerCase();
|
||||
if (unit === 's') {return n * 1000;}
|
||||
if (unit === 'm') {return n * 60_000;}
|
||||
if (unit === 'h') {return n * 3_600_000;}
|
||||
if (unit === 'd') {return n * 86_400_000;}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getElevationWindow(
|
||||
store: ElevationStore,
|
||||
options: {
|
||||
nowMs?: number;
|
||||
auditContext?: ElevationAuditContext;
|
||||
} = {},
|
||||
): ElevationWindowResult {
|
||||
const untilRaw = store.get(ELEVATION_UNTIL_KEY);
|
||||
const idRaw = store.get(ELEVATION_ID_KEY);
|
||||
const reasonRaw = store.get(ELEVATION_REASON_KEY);
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
|
||||
if (!untilRaw || !idRaw) {
|
||||
return { expired: false };
|
||||
}
|
||||
|
||||
const untilMs = Number.parseInt(untilRaw, 10);
|
||||
if (!Number.isFinite(untilMs)) {
|
||||
clearElevation(store);
|
||||
return { expired: false };
|
||||
}
|
||||
|
||||
if (untilMs <= nowMs) {
|
||||
clearElevation(store);
|
||||
emitExpiredAudit(options.auditContext, idRaw, untilMs, reasonRaw ?? undefined);
|
||||
return { expired: true };
|
||||
}
|
||||
|
||||
return {
|
||||
expired: false,
|
||||
window: {
|
||||
untilMs,
|
||||
id: idRaw,
|
||||
reason: reasonRaw ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getElevationStatusMessage(store: ElevationStore, options: ElevationStatusOptions = {}): string {
|
||||
const { showExpiredSuffix = false, reasonSeparator = ' — ' } = options;
|
||||
const result = getElevationWindow(store, {
|
||||
nowMs: options.nowMs,
|
||||
auditContext: options.auditContext,
|
||||
});
|
||||
if (!result.window) {
|
||||
if (result.expired && showExpiredSuffix) {
|
||||
return 'Elevated mode: off (expired)';
|
||||
}
|
||||
return 'Elevated mode: off';
|
||||
}
|
||||
|
||||
const remainingSec = Math.ceil((result.window.untilMs - (options.nowMs ?? Date.now())) / 1000);
|
||||
return `Elevated mode: on (${remainingSec}s remaining)${result.window.reason ? `${reasonSeparator}${result.window.reason}` : ''}`;
|
||||
}
|
||||
|
||||
export function setElevationFromInput(store: ElevationStore, input: string, options: SetElevationOptions = {}): string {
|
||||
const raw = input.trim();
|
||||
if (!raw) {
|
||||
return ELEVATION_USAGE_TEXT;
|
||||
}
|
||||
const parts = raw.split(/\s+/);
|
||||
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
|
||||
const filtered = parts.filter((p) => p !== '--yes' && p !== '--confirm');
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return ELEVATION_USAGE_TEXT;
|
||||
}
|
||||
|
||||
if (filtered[0] === 'off') {
|
||||
if (!hasYes) {
|
||||
return ELEVATION_DISABLE_CONFIRM_TEXT;
|
||||
}
|
||||
const existingId = store.get(ELEVATION_ID_KEY) ?? (options.idGenerator ?? randomUUID)();
|
||||
const existingUntilRaw = store.get(ELEVATION_UNTIL_KEY);
|
||||
const existingReason = store.get(ELEVATION_REASON_KEY) ?? undefined;
|
||||
clearElevation(store);
|
||||
if (options.auditContext) {
|
||||
const untilMs = existingUntilRaw ? Number.parseInt(existingUntilRaw, 10) : undefined;
|
||||
auditLogger?.securityElevationDisabled({
|
||||
session_id: options.auditContext.sessionId,
|
||||
channel: options.auditContext.channel,
|
||||
sender: options.auditContext.sender,
|
||||
elevation_id: existingId,
|
||||
until_ms: Number.isFinite(untilMs) ? untilMs : undefined,
|
||||
reason: existingReason || undefined,
|
||||
});
|
||||
}
|
||||
return 'Elevated mode: off';
|
||||
}
|
||||
|
||||
if (!hasYes) {
|
||||
return ELEVATION_ENABLE_CONFIRM_TEXT;
|
||||
}
|
||||
|
||||
const ttlMs = parseElevationDurationToMs(filtered[0]);
|
||||
if (!ttlMs) {
|
||||
return ELEVATION_INVALID_DURATION_TEXT;
|
||||
}
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const untilMs = nowMs + ttlMs;
|
||||
const reason = filtered.slice(1).join(' ').trim();
|
||||
const id = (options.idGenerator ?? randomUUID)();
|
||||
|
||||
store.set(ELEVATION_UNTIL_KEY, String(untilMs));
|
||||
store.set(ELEVATION_ID_KEY, id);
|
||||
if (reason) {
|
||||
store.set(ELEVATION_REASON_KEY, reason);
|
||||
} else {
|
||||
store.delete(ELEVATION_REASON_KEY);
|
||||
}
|
||||
|
||||
if (options.auditContext) {
|
||||
auditLogger?.securityElevationEnabled({
|
||||
session_id: options.auditContext.sessionId,
|
||||
channel: options.auditContext.channel,
|
||||
sender: options.auditContext.sender,
|
||||
elevation_id: id,
|
||||
until_ms: untilMs,
|
||||
ttl_ms: ttlMs,
|
||||
reason: reason || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return `Elevated mode: on until ${new Date(untilMs).toISOString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user