diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 663f9c3..f3af376 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -23,6 +23,7 @@ import type { HeartbeatRecoverEvent, GmailPollEvent, GmailNewEmailEvent, + SecurityElevationEvent, } from './types.js'; import { AuditRotator } from './rotation.js'; @@ -124,6 +125,21 @@ export class AuditLogger { }); } + securityElevationEnabled(event: SecurityElevationEvent): void { + if (!this.shouldLog('tools', 'info')) {return;} + this.write({ level: 'info', event_type: 'security.elevation.enabled', event: event as unknown as Record }); + } + + securityElevationDisabled(event: SecurityElevationEvent): void { + if (!this.shouldLog('tools', 'info')) {return;} + this.write({ level: 'info', event_type: 'security.elevation.disabled', event: event as unknown as Record }); + } + + securityElevationExpired(event: SecurityElevationEvent): void { + if (!this.shouldLog('tools', 'info')) {return;} + this.write({ level: 'info', event_type: 'security.elevation.expired', event: event as unknown as Record }); + } + // ── Session Events ─────────────────────────────────────────── sessionCreate(event: SessionCreateEvent): void { diff --git a/src/audit/types.ts b/src/audit/types.ts index 2e63288..809e3a7 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -3,6 +3,8 @@ export type AuditLevel = 'debug' | 'info' | 'warn' | 'error'; export type AuditEventType = // Tool execution | 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied' | 'tool.approval' + // Security + | 'security.elevation.enabled' | 'security.elevation.disabled' | 'security.elevation.expired' // Skills scan | 'skills.scan.pass' | 'skills.scan.fail' // Skills installer @@ -134,6 +136,16 @@ export interface SkillsScanEvent { issue_codes: string[]; } +export interface SecurityElevationEvent { + session_id: string; + channel: string; + sender: string; + elevation_id: string; + until_ms?: number; + ttl_ms?: number; + reason?: string; +} + export interface SessionCreateEvent { session_id: string; frontend: string; diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index 41b322c..4ed74c1 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createModelCommand } from './index.js'; +import { createElevateCommand, createModelCommand } from './index.js'; describe('builtin /model command', () => { it('passes through the full argument string', async () => { @@ -35,3 +35,35 @@ describe('builtin /model command', () => { expect(result).toEqual({ handled: true, text: 'switched' }); }); }); + +describe('builtin /elevate command', () => { + it('passes through the full argument string', async () => { + const cmd = createElevateCommand(); + const setElevation = vi.fn(() => 'ok'); + + const result = await cmd.execute(['10m', 'reason', '--yes'], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/elevate 10m reason --yes', + services: { setElevation }, + }); + + expect(setElevation).toHaveBeenCalledWith('10m reason --yes'); + expect(result).toEqual({ handled: true, text: 'ok' }); + }); + + it('shows status when no args are provided', async () => { + const cmd = createElevateCommand(); + const getElevation = vi.fn(() => 'status'); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/elevate', + services: { getElevation }, + }); + expect(getElevation).toHaveBeenCalledOnce(); + expect(result).toEqual({ handled: true, text: 'status' }); + }); +}); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index 983ca23..1d89c12 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -128,6 +128,27 @@ export function createResetCommand(): CommandDefinition { }; } +export function createElevateCommand(): CommandDefinition { + return { + name: 'elevate', + description: 'Enable or disable time-bounded elevated host mode', + execute: async (args, ctx) => { + if (args.length === 0) { + if (!ctx.services?.getElevation) { + return notAvailable('Elevate command'); + } + return { handled: true, text: await ctx.services.getElevation() }; + } + + if (!ctx.services?.setElevation) { + return notAvailable('Elevate command'); + } + + return { handled: true, text: await ctx.services.setElevation(args.join(' ')) }; + }, + }; +} + export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createHelpCommand(registry)); registry.register(createStatusCommand()); @@ -135,4 +156,5 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createModelCommand()); registry.register(createCompactCommand()); registry.register(createResetCommand()); + registry.register(createElevateCommand()); } diff --git a/src/commands/types.ts b/src/commands/types.ts index b83ad23..cd7a2d3 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -25,4 +25,7 @@ export interface CommandServices { setModel?: (tier: string) => Promise | string; compact?: () => Promise | string; reset?: () => Promise | string; + + getElevation?: () => Promise | string; + setElevation?: (input: string) => Promise | string; } diff --git a/src/tools/policy.ts b/src/tools/policy.ts index f60a0fc..fc86101 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -157,6 +157,13 @@ export interface ToolPolicyContext { /** True when untrusted content has been introduced in this run. */ untrustedContent?: boolean; + + /** Elevated mode (break-glass): allow host execution for high-risk tools until this epoch millis. */ + elevatedHostUntilMs?: number; + /** User-supplied reason for elevation (audited separately). */ + elevatedHostReason?: string; + /** Correlation id for elevation window. */ + elevatedHostId?: string; } function resolveSkillAllowedNames(allToolNames: string[], permissions?: SkillPermissions): Set | null {