From baa53f91d98ce8c6a1d87ba5f11d034d5f9b008c Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 19 Feb 2026 11:41:53 -0800 Subject: [PATCH] refactor(security): unify elevated mode handling across surfaces --- docs/plans/2026-02-15-openclaw-gap-roadmap.md | 18 +- docs/plans/state.json | 19 ++ docs/security/SAFE_PERSONAL_AGENT.md | 20 ++ src/backends/native/agent.ts | 43 ++-- src/daemon/routing.ts | 121 ++-------- src/frontends/tui/components/App.tsx | 88 +------- src/frontends/tui/minimal.ts | 92 +------- src/gateway/handlers/agent.ts | 120 ++-------- src/security/elevation.test.ts | 136 +++++++++++ src/security/elevation.ts | 213 ++++++++++++++++++ 10 files changed, 467 insertions(+), 403 deletions(-) create mode 100644 src/security/elevation.test.ts create mode 100644 src/security/elevation.ts diff --git a/docs/plans/2026-02-15-openclaw-gap-roadmap.md b/docs/plans/2026-02-15-openclaw-gap-roadmap.md index ed5daee..1a0c93e 100644 --- a/docs/plans/2026-02-15-openclaw-gap-roadmap.md +++ b/docs/plans/2026-02-15-openclaw-gap-roadmap.md @@ -38,10 +38,10 @@ A gap item is considered implemented when: - QMD backend (experimental) — completed on 2026-02-16 -### Security (MISSING) +### Security -- Skill/plugin code safety scanner (static analysis) -- Elevated mode (explicit host-exec escape hatch) +- Skill/plugin code safety scanner (static analysis) — completed on 2026-02-16 +- Elevated mode (explicit host-exec escape hatch) — completed on 2026-02-16, hardening pass completed on 2026-02-19 (shared elevation module + parity refactor) ### Skills Ecosystem (MISSING) @@ -246,6 +246,8 @@ Optional second insertion: ## Milestone 4 (P2): Elevated Mode (Break Glass) +Status: completed (2026-02-16), hardened and unified on 2026-02-19. + ### Scope Add a user-visible, auditable, time-bounded mechanism to permit host execution of high-risk tools. @@ -265,6 +267,7 @@ Constraints: ### Tests - Unit tests for TTL expiry and denial without elevation. +- Cross-surface parity tests for command behavior (`daemon`/`gateway`/`tui`) and shared helper tests (`src/security/elevation.test.ts`). --- @@ -329,9 +332,6 @@ These are substantial UX/ecosystem projects or highly platform-specific; defer u ## Suggested Next Execution Order -1) Credential System v2 (API + OAuth/token) -2) Vercel AI Gateway provider -3) Skill safety scanner -4) Elevated mode -5) Matrix adapter -6) Deployment targets +1) Auth profile rotation/stickiness before provider fallback +2) Queue/run-control polish (interrupt preemption telemetry + UX) +3) Daily memory continuity tuning (if continuity quality is still lacking) diff --git a/docs/plans/state.json b/docs/plans/state.json index ba7c341..b12da3f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5756,6 +5756,25 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts passing" + }, + "elevation-hardening-unification": { + "status": "completed", + "date": "2026-02-19", + "updated": "2026-02-19", + "summary": "Unified elevated mode behavior into a shared `src/security/elevation.ts` module and refactored daemon, gateway, native agent, and TUI surfaces to use it. This removes duplicated TTL/expiry parsing and keeps `/elevate` semantics/auditing consistent across execution paths.", + "files_modified": [ + "src/security/elevation.ts", + "src/security/elevation.test.ts", + "src/daemon/routing.ts", + "src/gateway/handlers/agent.ts", + "src/backends/native/agent.ts", + "src/frontends/tui/minimal.ts", + "src/frontends/tui/components/App.tsx", + "docs/security/SAFE_PERSONAL_AGENT.md", + "docs/plans/2026-02-15-openclaw-gap-roadmap.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/security/elevation.test.ts src/gateway/handlers/agent.test.ts src/frontends/tui/minimal.test.ts src/backends/native/agent.test.ts src/daemon/routing.test.ts src/commands/builtin/index.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/docs/security/SAFE_PERSONAL_AGENT.md b/docs/security/SAFE_PERSONAL_AGENT.md index a4c4a89..5b27383 100644 --- a/docs/security/SAFE_PERSONAL_AGENT.md +++ b/docs/security/SAFE_PERSONAL_AGENT.md @@ -158,6 +158,26 @@ Behavior: Note: today, only `shell.exec` and `process.start` are replaced with sandboxed implementations. Other high-risk tools are blocked-by-default in skill contexts unless host mode is explicitly allowed. +## Elevated Mode (Break Glass) + +Flynn supports a time-bounded `/elevate` escape hatch for host execution of sensitive tools. + +- Session keys: `elevation.until_ms`, `elevation.id`, `elevation.reason` +- Command UX requires explicit confirmation (`--yes` / `--confirm`) +- Expiry is automatic (TTL-based) and emits audit events + +Implementation is centralized in `src/security/elevation.ts` and reused by: + +- `src/daemon/routing.ts` (channel command fast path) +- `src/gateway/handlers/agent.ts` (websocket/gateway command fast path) +- `src/frontends/tui/minimal.ts` and `src/frontends/tui/components/App.tsx` (TUI command surfaces) +- `src/backends/native/agent.ts` (per-tool-call elevation context resolution) + +Tool enforcement remains in `src/tools/executor.ts`: + +- host-sensitive tools are denied when elevation is required but inactive +- elevated host high-risk calls still require explicit confirmation via hooks + ## Prompt Injection Mitigation Flynn uses a practical defense-in-depth approach: diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index 464f2e8..ec3aee9 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -5,10 +5,10 @@ import type { ToolRegistry } from '../../tools/registry.js'; import type { ToolExecutor } from '../../tools/executor.js'; import type { ToolResult } from '../../tools/types.js'; import type { ToolPolicyContext } from '../../tools/policy.js'; -import { auditLogger } from '../../audit/index.js'; import type { Attachment } from '../../channels/types.js'; import type { OutboundAttachmentCollector } from './attachments.js'; import { buildUserMessage } from '../../models/media.js'; +import { getElevationWindow } from '../../security/elevation.js'; export interface ToolUseEvent { type: 'start' | 'end'; @@ -325,32 +325,21 @@ export class NativeAgent { let elevationId: string | undefined; if (this.session) { - const untilRaw = this.session.getConfig('elevation.until_ms'); - const idRaw = this.session.getConfig('elevation.id'); - const reasonRaw = this.session.getConfig('elevation.reason'); - if (untilRaw && idRaw) { - const untilMs = Number.parseInt(untilRaw, 10); - if (Number.isFinite(untilMs)) { - const now = Date.now(); - if (untilMs > now) { - elevationUntilMs = untilMs; - elevationId = idRaw; - elevationReason = reasonRaw ?? undefined; - } else { - // Auto-expire elevation. - this.session.deleteConfig('elevation.until_ms'); - this.session.deleteConfig('elevation.reason'); - this.session.deleteConfig('elevation.id'); - auditLogger?.securityElevationExpired({ - session_id: this.session.id, - channel: this._toolPolicyContext?.channel ?? 'unknown', - sender: this._toolPolicyContext?.sender ?? 'unknown', - elevation_id: idRaw, - until_ms: untilMs, - reason: reasonRaw ?? undefined, - }); - } - } + const elevation = getElevationWindow({ + get: (key) => this.session!.getConfig(key), + set: (key, value) => this.session!.setConfig(key, value), + delete: (key) => this.session!.deleteConfig(key), + }, { + auditContext: { + sessionId: this.session.id, + channel: this._toolPolicyContext?.channel ?? 'unknown', + sender: this._toolPolicyContext?.sender ?? 'unknown', + }, + }); + if (elevation.window) { + elevationUntilMs = elevation.window.untilMs; + elevationId = elevation.window.id; + elevationReason = elevation.window.reason; } } diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index ba18a1d..b11504c 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -26,7 +26,7 @@ import { matchReactionPrompt } from '../automation/reactions.js'; import { loadSkillRegistryCatalog } from '../skills/index.js'; import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js'; import { auditLogger } from '../audit/index.js'; -import { randomUUID } from 'crypto'; +import { getElevationStatusMessage, setElevationFromInput } from '../security/elevation.js'; import { dirname, resolve } from 'path'; function buildProviderConfigMap(config: Config): Partial> { @@ -813,115 +813,32 @@ export function createMessageRouter(deps: { }, getElevation: () => { - const untilRaw = session.getConfig('elevation.until_ms'); - const reason = session.getConfig('elevation.reason') ?? ''; - const id = session.getConfig('elevation.id') ?? ''; - if (!untilRaw || !id) { - return 'Elevated mode: off'; - } - const untilMs = Number.parseInt(untilRaw, 10); - if (!Number.isFinite(untilMs)) { - return 'Elevated mode: off'; - } - const now = Date.now(); - if (untilMs <= now) { - session.deleteConfig('elevation.until_ms'); - session.deleteConfig('elevation.reason'); - session.deleteConfig('elevation.id'); - auditLogger?.securityElevationExpired({ - session_id: session.id, + return getElevationStatusMessage({ + get: (key) => session.getConfig(key), + set: (key, value) => session.setConfig(key, value), + delete: (key) => session.deleteConfig(key), + }, { + showExpiredSuffix: true, + auditContext: { + sessionId: session.id, channel: msg.channel, sender: msg.senderId, - elevation_id: id, - until_ms: untilMs, - reason: reason || undefined, - }); - return 'Elevated mode: off (expired)'; - } - const remainingMs = untilMs - now; - const remainingSec = Math.ceil(remainingMs / 1000); - return `Elevated mode: on (${remainingSec}s remaining)${reason ? ` — ${reason}` : ''}`; + }, + }); }, setElevation: (input: string) => { - const raw = input.trim(); - 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 'Usage: /elevate --yes | /elevate off --yes'; - } - - if (filtered[0] === 'off') { - if (!hasYes) { - return 'Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes'; - } - const existingId = session.getConfig('elevation.id') ?? randomUUID(); - const existingUntil = session.getConfig('elevation.until_ms'); - const existingReason = session.getConfig('elevation.reason') ?? ''; - session.deleteConfig('elevation.until_ms'); - session.deleteConfig('elevation.reason'); - session.deleteConfig('elevation.id'); - auditLogger?.securityElevationDisabled({ - session_id: session.id, + return setElevationFromInput({ + get: (key) => session.getConfig(key), + set: (key, value) => session.setConfig(key, value), + delete: (key) => session.deleteConfig(key), + }, input, { + auditContext: { + sessionId: session.id, channel: msg.channel, sender: msg.senderId, - elevation_id: existingId, - until_ms: existingUntil ? Number.parseInt(existingUntil, 10) : undefined, - reason: existingReason || undefined, - }); - return 'Elevated mode: off'; - } - - if (!hasYes) { - return 'Refusing to enable elevation without explicit confirmation. Use: /elevate --yes'; - } - - const dur = filtered[0]; - const reason = filtered.slice(1).join(' ').trim(); - const ttlMs = (() => { - const m = dur.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; - })(); - - if (!ttlMs) { - return 'Invalid duration. Use one of: 30s, 10m, 1h, 1d'; - } - - const untilMs = Date.now() + ttlMs; - const id = randomUUID(); - session.setConfig('elevation.until_ms', String(untilMs)); - session.setConfig('elevation.id', id); - if (reason) { - session.setConfig('elevation.reason', reason); - } else { - session.deleteConfig('elevation.reason'); - } - - auditLogger?.securityElevationEnabled({ - session_id: session.id, - channel: msg.channel, - sender: msg.senderId, - elevation_id: id, - until_ms: untilMs, - ttl_ms: ttlMs, - reason: reason || undefined, + }, }); - - return `Elevated mode: on until ${new Date(untilMs).toISOString()}`; }, getQueue: () => { diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index dd975e8..5e67298 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -16,6 +16,7 @@ import { estimateMessageTokens, getContextWindow } from '../../../context/tokens import type { PairingManager } from '../../../channels/pairing.js'; import { loginGitHub, loginOpenAI } from '../../../auth/index.js'; import { OllamaClient, LlamaCppClient } from '../../../models/index.js'; +import { getElevationStatusMessage, setElevationFromInput } from '../../../security/elevation.js'; /** Format a tool name like "gmail.list" -> "Gmail: List" */ function formatToolName(name: string): string { @@ -226,23 +227,6 @@ export function App({ return null; }, []); - const parseDurationToMs = useCallback((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; - }, []); - const handleSubmit = useCallback(async (value: string) => { const command = parseCommand(value); if (!command) {return;} @@ -664,75 +648,18 @@ export function App({ } case 'elevate': { - const untilRaw = session.getConfig('elevation.until_ms'); - const reason = session.getConfig('elevation.reason') ?? ''; - const id = session.getConfig('elevation.id') ?? ''; - const showStatus = () => { - if (!untilRaw || !id) { - pushAssistantMessage('Elevated mode: off'); - return; - } - const untilMs = Number.parseInt(untilRaw, 10); - if (!Number.isFinite(untilMs) || untilMs <= Date.now()) { - session.deleteConfig('elevation.until_ms'); - session.deleteConfig('elevation.reason'); - session.deleteConfig('elevation.id'); - pushAssistantMessage('Elevated mode: off'); - return; - } - const remainingSec = Math.ceil((untilMs - Date.now()) / 1000); - pushAssistantMessage(`Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}`); + const store = { + get: (key: string) => session.getConfig(key), + set: (key: string, value: string) => session.setConfig(key, value), + delete: (key: string) => session.deleteConfig(key), }; const raw = (command.args ?? '').trim(); if (!raw) { - showStatus(); + pushAssistantMessage(getElevationStatusMessage(store, { reasonSeparator: ' - ' })); return; } - - 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) { - pushAssistantMessage('Usage: /elevate --yes | /elevate off --yes'); - return; - } - - if (filtered[0] === 'off') { - if (!hasYes) { - pushAssistantMessage('Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes'); - return; - } - session.deleteConfig('elevation.until_ms'); - session.deleteConfig('elevation.reason'); - session.deleteConfig('elevation.id'); - pushAssistantMessage('Elevated mode: off'); - return; - } - - if (!hasYes) { - pushAssistantMessage('Refusing to enable elevation without explicit confirmation. Use: /elevate --yes'); - return; - } - - const ttlMs = parseDurationToMs(filtered[0]); - if (!ttlMs) { - pushAssistantMessage('Invalid duration. Use one of: 30s, 10m, 1h, 1d'); - return; - } - - const reasonText = filtered.slice(1).join(' ').trim(); - const untilMs = Date.now() + ttlMs; - const newId = `${untilMs}`; - session.setConfig('elevation.until_ms', String(untilMs)); - session.setConfig('elevation.id', newId); - if (reasonText) { - session.setConfig('elevation.reason', reasonText); - } else { - session.deleteConfig('elevation.reason'); - } - pushAssistantMessage(`Elevated mode: on until ${new Date(untilMs).toISOString()}`); + pushAssistantMessage(setElevationFromInput(store, raw)); return; } @@ -836,7 +763,6 @@ export function App({ pushAssistantMessage, getAvailableBackends, createLocalClient, - parseDurationToMs, localProviders, currentLocalProvider, pairingManager, diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 757d0e0..855c9e9 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -26,6 +26,7 @@ import type { PairingManager } from '../../channels/pairing.js'; import { getColoredBanner } from './banner.js'; import type { HookEngine } from '../../hooks/index.js'; import { estimateMessageTokens, getContextWindow } from '../../context/tokens.js'; +import { getElevationStatusMessage, setElevationFromInput } from '../../security/elevation.js'; export { parseCommand, type Command }; @@ -646,94 +647,19 @@ export class MinimalTui { } private handleElevateCommand(args?: string): void { - const untilRaw = this.config.session.getConfig('elevation.until_ms'); - const reason = this.config.session.getConfig('elevation.reason') ?? ''; - const id = this.config.session.getConfig('elevation.id') ?? ''; - - const showStatus = () => { - if (!untilRaw || !id) { - console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`); - return; - } - const untilMs = Number.parseInt(untilRaw, 10); - if (!Number.isFinite(untilMs) || untilMs <= Date.now()) { - this.config.session.deleteConfig('elevation.until_ms'); - this.config.session.deleteConfig('elevation.reason'); - this.config.session.deleteConfig('elevation.id'); - console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`); - return; - } - const remainingSec = Math.ceil((untilMs - Date.now()) / 1000); - console.log(`${colors.gray}Elevated mode: on (${remainingSec}s remaining)${reason ? ` - ${reason}` : ''}${colors.reset}\n`); + const store = { + get: (key: string) => this.config.session.getConfig(key), + set: (key: string, value: string) => this.config.session.setConfig(key, value), + delete: (key: string) => this.config.session.deleteConfig(key), }; - const raw = (args ?? '').trim(); if (!raw) { - showStatus(); + const status = getElevationStatusMessage(store, { reasonSeparator: ' - ' }); + console.log(`${colors.gray}${status}${colors.reset}\n`); return; } - - 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) { - console.log(`${colors.gray}Usage: /elevate --yes | /elevate off --yes${colors.reset}\n`); - return; - } - - if (filtered[0] === 'off') { - if (!hasYes) { - console.log(`${colors.gray}Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes${colors.reset}\n`); - return; - } - this.config.session.deleteConfig('elevation.until_ms'); - this.config.session.deleteConfig('elevation.reason'); - this.config.session.deleteConfig('elevation.id'); - console.log(`${colors.gray}Elevated mode: off${colors.reset}\n`); - return; - } - - if (!hasYes) { - console.log(`${colors.gray}Refusing to enable elevation without explicit confirmation. Use: /elevate --yes${colors.reset}\n`); - return; - } - - const ttlMs = this.parseDurationToMs(filtered[0]); - if (!ttlMs) { - console.log(`${colors.gray}Invalid duration. Use one of: 30s, 10m, 1h, 1d${colors.reset}\n`); - return; - } - - const reasonText = filtered.slice(1).join(' ').trim(); - const untilMs = Date.now() + ttlMs; - const newId = `${untilMs}`; - this.config.session.setConfig('elevation.until_ms', String(untilMs)); - this.config.session.setConfig('elevation.id', newId); - if (reasonText) { - this.config.session.setConfig('elevation.reason', reasonText); - } else { - this.config.session.deleteConfig('elevation.reason'); - } - - console.log(`${colors.gray}Elevated mode: on until ${new Date(untilMs).toISOString()}${colors.reset}\n`); - } - - private parseDurationToMs(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; + const output = setElevationFromInput(store, raw); + console.log(`${colors.gray}${output}${colors.reset}\n`); } private handleModelCommand(name?: string, providerModel?: string): void { diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index adec783..5227311 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -16,7 +16,7 @@ import { MODEL_PROVIDERS } from '../../config/index.js'; import { createClientFromConfig } from '../../daemon/models.js'; import { auditLogger } from '../../audit/index.js'; import type { HookEngine } from '../../hooks/index.js'; -import { randomUUID } from 'crypto'; +import { getElevationStatusMessage, setElevationFromInput } from '../../security/elevation.js'; export interface AgentHandlerDeps { sessionBridge: SessionBridge; @@ -391,117 +391,35 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { if (!sessionId || !deps.sessionManager) { return 'Elevated mode: off'; } - const untilRaw = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.until_ms'); - const reason = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.reason') ?? ''; - const id = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.id') ?? ''; - if (!untilRaw || !id) { - return 'Elevated mode: off'; - } - const untilMs = Number.parseInt(untilRaw, 10); - if (!Number.isFinite(untilMs)) { - return 'Elevated mode: off'; - } - const now = Date.now(); - if (untilMs <= now) { - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.until_ms'); - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.reason'); - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.id'); - auditLogger?.securityElevationExpired({ - session_id: `ws:${sessionId}`, + return getElevationStatusMessage({ + get: (key) => deps.sessionManager!.getSessionConfig('ws', sessionId, key), + set: (key, value) => deps.sessionManager!.setSessionConfig('ws', sessionId, key, value), + delete: (key) => deps.sessionManager!.deleteSessionConfig('ws', sessionId, key), + }, { + showExpiredSuffix: true, + auditContext: { + sessionId: `ws:${sessionId}`, channel: 'ws', sender: connectionId, - elevation_id: id, - until_ms: untilMs, - reason: reason || undefined, - }); - return 'Elevated mode: off (expired)'; - } - const remainingMs = untilMs - now; - const remainingSec = Math.ceil(remainingMs / 1000); - return `Elevated mode: on (${remainingSec}s remaining)${reason ? ` — ${reason}` : ''}`; + }, + }); }, setElevation: (input: string) => { if (!sessionId || !deps.sessionManager) { return 'Elevate command is not available in this session.'; } - const raw = input.trim(); - 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 'Usage: /elevate --yes | /elevate off --yes'; - } - - if (filtered[0] === 'off') { - if (!hasYes) { - return 'Refusing to disable elevation without explicit confirmation. Use: /elevate off --yes'; - } - const existingId = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.id') ?? randomUUID(); - const existingUntil = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.until_ms'); - const existingReason = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.reason') ?? ''; - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.until_ms'); - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.reason'); - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.id'); - auditLogger?.securityElevationDisabled({ - session_id: `ws:${sessionId}`, + return setElevationFromInput({ + get: (key) => deps.sessionManager!.getSessionConfig('ws', sessionId, key), + set: (key, value) => deps.sessionManager!.setSessionConfig('ws', sessionId, key, value), + delete: (key) => deps.sessionManager!.deleteSessionConfig('ws', sessionId, key), + }, input, { + auditContext: { + sessionId: `ws:${sessionId}`, channel: 'ws', sender: connectionId, - elevation_id: existingId, - until_ms: existingUntil ? Number.parseInt(existingUntil, 10) : undefined, - reason: existingReason || undefined, - }); - return 'Elevated mode: off'; - } - - if (!hasYes) { - return 'Refusing to enable elevation without explicit confirmation. Use: /elevate --yes'; - } - - const dur = filtered[0]; - const reason = filtered.slice(1).join(' ').trim(); - const ttlMs = (() => { - const m = dur.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; - })(); - if (!ttlMs) { - return 'Invalid duration. Use one of: 30s, 10m, 1h, 1d'; - } - - const untilMs = Date.now() + ttlMs; - const id = randomUUID(); - deps.sessionManager.setSessionConfig('ws', sessionId, 'elevation.until_ms', String(untilMs)); - deps.sessionManager.setSessionConfig('ws', sessionId, 'elevation.id', id); - if (reason) { - deps.sessionManager.setSessionConfig('ws', sessionId, 'elevation.reason', reason); - } else { - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'elevation.reason'); - } - - auditLogger?.securityElevationEnabled({ - session_id: `ws:${sessionId}`, - channel: 'ws', - sender: connectionId, - elevation_id: id, - until_ms: untilMs, - ttl_ms: ttlMs, - reason: reason || undefined, + }, }); - - return `Elevated mode: on until ${new Date(untilMs).toISOString()}`; }, getQueue: () => { diff --git a/src/security/elevation.test.ts b/src/security/elevation.test.ts new file mode 100644 index 0000000..95182c2 --- /dev/null +++ b/src/security/elevation.test.ts @@ -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): ElevationStore { + const values = new Map(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[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); + }); +}); diff --git a/src/security/elevation.ts b/src/security/elevation.ts new file mode 100644 index 0000000..6be4791 --- /dev/null +++ b/src/security/elevation.ts @@ -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 --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 --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()}`; +}