refactor(security): unify elevated mode handling across surfaces
This commit is contained in:
@@ -38,10 +38,10 @@ A gap item is considered implemented when:
|
|||||||
|
|
||||||
- QMD backend (experimental) — completed on 2026-02-16
|
- QMD backend (experimental) — completed on 2026-02-16
|
||||||
|
|
||||||
### Security (MISSING)
|
### Security
|
||||||
|
|
||||||
- Skill/plugin code safety scanner (static analysis)
|
- Skill/plugin code safety scanner (static analysis) — completed on 2026-02-16
|
||||||
- Elevated mode (explicit host-exec escape hatch)
|
- 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)
|
### Skills Ecosystem (MISSING)
|
||||||
|
|
||||||
@@ -246,6 +246,8 @@ Optional second insertion:
|
|||||||
|
|
||||||
## Milestone 4 (P2): Elevated Mode (Break Glass)
|
## Milestone 4 (P2): Elevated Mode (Break Glass)
|
||||||
|
|
||||||
|
Status: completed (2026-02-16), hardened and unified on 2026-02-19.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
Add a user-visible, auditable, time-bounded mechanism to permit host execution of high-risk tools.
|
Add a user-visible, auditable, time-bounded mechanism to permit host execution of high-risk tools.
|
||||||
@@ -265,6 +267,7 @@ Constraints:
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- Unit tests for TTL expiry and denial without elevation.
|
- 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
|
## Suggested Next Execution Order
|
||||||
|
|
||||||
1) Credential System v2 (API + OAuth/token)
|
1) Auth profile rotation/stickiness before provider fallback
|
||||||
2) Vercel AI Gateway provider
|
2) Queue/run-control polish (interrupt preemption telemetry + UX)
|
||||||
3) Skill safety scanner
|
3) Daily memory continuity tuning (if continuity quality is still lacking)
|
||||||
4) Elevated mode
|
|
||||||
5) Matrix adapter
|
|
||||||
6) Deployment targets
|
|
||||||
|
|||||||
@@ -5756,6 +5756,25 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/frontends/tui/minimal.test.ts passing"
|
"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": {
|
"overall_progress": {
|
||||||
|
|||||||
@@ -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.
|
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
|
## Prompt Injection Mitigation
|
||||||
|
|
||||||
Flynn uses a practical defense-in-depth approach:
|
Flynn uses a practical defense-in-depth approach:
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import type { ToolRegistry } from '../../tools/registry.js';
|
|||||||
import type { ToolExecutor } from '../../tools/executor.js';
|
import type { ToolExecutor } from '../../tools/executor.js';
|
||||||
import type { ToolResult } from '../../tools/types.js';
|
import type { ToolResult } from '../../tools/types.js';
|
||||||
import type { ToolPolicyContext } from '../../tools/policy.js';
|
import type { ToolPolicyContext } from '../../tools/policy.js';
|
||||||
import { auditLogger } from '../../audit/index.js';
|
|
||||||
import type { Attachment } from '../../channels/types.js';
|
import type { Attachment } from '../../channels/types.js';
|
||||||
import type { OutboundAttachmentCollector } from './attachments.js';
|
import type { OutboundAttachmentCollector } from './attachments.js';
|
||||||
import { buildUserMessage } from '../../models/media.js';
|
import { buildUserMessage } from '../../models/media.js';
|
||||||
|
import { getElevationWindow } from '../../security/elevation.js';
|
||||||
|
|
||||||
export interface ToolUseEvent {
|
export interface ToolUseEvent {
|
||||||
type: 'start' | 'end';
|
type: 'start' | 'end';
|
||||||
@@ -325,32 +325,21 @@ export class NativeAgent {
|
|||||||
let elevationId: string | undefined;
|
let elevationId: string | undefined;
|
||||||
|
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
const untilRaw = this.session.getConfig('elevation.until_ms');
|
const elevation = getElevationWindow({
|
||||||
const idRaw = this.session.getConfig('elevation.id');
|
get: (key) => this.session!.getConfig(key),
|
||||||
const reasonRaw = this.session.getConfig('elevation.reason');
|
set: (key, value) => this.session!.setConfig(key, value),
|
||||||
if (untilRaw && idRaw) {
|
delete: (key) => this.session!.deleteConfig(key),
|
||||||
const untilMs = Number.parseInt(untilRaw, 10);
|
}, {
|
||||||
if (Number.isFinite(untilMs)) {
|
auditContext: {
|
||||||
const now = Date.now();
|
sessionId: this.session.id,
|
||||||
if (untilMs > now) {
|
channel: this._toolPolicyContext?.channel ?? 'unknown',
|
||||||
elevationUntilMs = untilMs;
|
sender: this._toolPolicyContext?.sender ?? 'unknown',
|
||||||
elevationId = idRaw;
|
},
|
||||||
elevationReason = reasonRaw ?? undefined;
|
});
|
||||||
} else {
|
if (elevation.window) {
|
||||||
// Auto-expire elevation.
|
elevationUntilMs = elevation.window.untilMs;
|
||||||
this.session.deleteConfig('elevation.until_ms');
|
elevationId = elevation.window.id;
|
||||||
this.session.deleteConfig('elevation.reason');
|
elevationReason = elevation.window.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-102
@@ -26,7 +26,7 @@ import { matchReactionPrompt } from '../automation/reactions.js';
|
|||||||
import { loadSkillRegistryCatalog } from '../skills/index.js';
|
import { loadSkillRegistryCatalog } from '../skills/index.js';
|
||||||
import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
|
import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
|
||||||
import { auditLogger } from '../audit/index.js';
|
import { auditLogger } from '../audit/index.js';
|
||||||
import { randomUUID } from 'crypto';
|
import { getElevationStatusMessage, setElevationFromInput } from '../security/elevation.js';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
|
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
|
||||||
@@ -813,115 +813,32 @@ export function createMessageRouter(deps: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getElevation: () => {
|
getElevation: () => {
|
||||||
const untilRaw = session.getConfig('elevation.until_ms');
|
return getElevationStatusMessage({
|
||||||
const reason = session.getConfig('elevation.reason') ?? '';
|
get: (key) => session.getConfig(key),
|
||||||
const id = session.getConfig('elevation.id') ?? '';
|
set: (key, value) => session.setConfig(key, value),
|
||||||
if (!untilRaw || !id) {
|
delete: (key) => session.deleteConfig(key),
|
||||||
return 'Elevated mode: off';
|
}, {
|
||||||
}
|
showExpiredSuffix: true,
|
||||||
const untilMs = Number.parseInt(untilRaw, 10);
|
auditContext: {
|
||||||
if (!Number.isFinite(untilMs)) {
|
sessionId: session.id,
|
||||||
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,
|
|
||||||
channel: msg.channel,
|
channel: msg.channel,
|
||||||
sender: msg.senderId,
|
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) => {
|
setElevation: (input: string) => {
|
||||||
const raw = input.trim();
|
return setElevationFromInput({
|
||||||
const parts = raw.split(/\s+/);
|
get: (key) => session.getConfig(key),
|
||||||
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
|
set: (key, value) => session.setConfig(key, value),
|
||||||
const filtered = parts.filter(p => p !== '--yes' && p !== '--confirm');
|
delete: (key) => session.deleteConfig(key),
|
||||||
|
}, input, {
|
||||||
if (filtered.length === 0) {
|
auditContext: {
|
||||||
return 'Usage: /elevate <duration> <reason...> --yes | /elevate off --yes';
|
sessionId: session.id,
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
channel: msg.channel,
|
channel: msg.channel,
|
||||||
sender: msg.senderId,
|
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 <duration> <reason...> --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: () => {
|
getQueue: () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { estimateMessageTokens, getContextWindow } from '../../../context/tokens
|
|||||||
import type { PairingManager } from '../../../channels/pairing.js';
|
import type { PairingManager } from '../../../channels/pairing.js';
|
||||||
import { loginGitHub, loginOpenAI } from '../../../auth/index.js';
|
import { loginGitHub, loginOpenAI } from '../../../auth/index.js';
|
||||||
import { OllamaClient, LlamaCppClient } from '../../../models/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" */
|
/** Format a tool name like "gmail.list" -> "Gmail: List" */
|
||||||
function formatToolName(name: string): string {
|
function formatToolName(name: string): string {
|
||||||
@@ -226,23 +227,6 @@ export function App({
|
|||||||
return null;
|
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 handleSubmit = useCallback(async (value: string) => {
|
||||||
const command = parseCommand(value);
|
const command = parseCommand(value);
|
||||||
if (!command) {return;}
|
if (!command) {return;}
|
||||||
@@ -664,75 +648,18 @@ export function App({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'elevate': {
|
case 'elevate': {
|
||||||
const untilRaw = session.getConfig('elevation.until_ms');
|
const store = {
|
||||||
const reason = session.getConfig('elevation.reason') ?? '';
|
get: (key: string) => session.getConfig(key),
|
||||||
const id = session.getConfig('elevation.id') ?? '';
|
set: (key: string, value: string) => session.setConfig(key, value),
|
||||||
const showStatus = () => {
|
delete: (key: string) => session.deleteConfig(key),
|
||||||
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 raw = (command.args ?? '').trim();
|
const raw = (command.args ?? '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
showStatus();
|
pushAssistantMessage(getElevationStatusMessage(store, { reasonSeparator: ' - ' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pushAssistantMessage(setElevationFromInput(store, raw));
|
||||||
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 <duration> <reason...> --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 <duration> <reason...> --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()}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +763,6 @@ export function App({
|
|||||||
pushAssistantMessage,
|
pushAssistantMessage,
|
||||||
getAvailableBackends,
|
getAvailableBackends,
|
||||||
createLocalClient,
|
createLocalClient,
|
||||||
parseDurationToMs,
|
|
||||||
localProviders,
|
localProviders,
|
||||||
currentLocalProvider,
|
currentLocalProvider,
|
||||||
pairingManager,
|
pairingManager,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { PairingManager } from '../../channels/pairing.js';
|
|||||||
import { getColoredBanner } from './banner.js';
|
import { getColoredBanner } from './banner.js';
|
||||||
import type { HookEngine } from '../../hooks/index.js';
|
import type { HookEngine } from '../../hooks/index.js';
|
||||||
import { estimateMessageTokens, getContextWindow } from '../../context/tokens.js';
|
import { estimateMessageTokens, getContextWindow } from '../../context/tokens.js';
|
||||||
|
import { getElevationStatusMessage, setElevationFromInput } from '../../security/elevation.js';
|
||||||
|
|
||||||
export { parseCommand, type Command };
|
export { parseCommand, type Command };
|
||||||
|
|
||||||
@@ -646,94 +647,19 @@ export class MinimalTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleElevateCommand(args?: string): void {
|
private handleElevateCommand(args?: string): void {
|
||||||
const untilRaw = this.config.session.getConfig('elevation.until_ms');
|
const store = {
|
||||||
const reason = this.config.session.getConfig('elevation.reason') ?? '';
|
get: (key: string) => this.config.session.getConfig(key),
|
||||||
const id = this.config.session.getConfig('elevation.id') ?? '';
|
set: (key: string, value: string) => this.config.session.setConfig(key, value),
|
||||||
|
delete: (key: string) => this.config.session.deleteConfig(key),
|
||||||
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 raw = (args ?? '').trim();
|
const raw = (args ?? '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
showStatus();
|
const status = getElevationStatusMessage(store, { reasonSeparator: ' - ' });
|
||||||
|
console.log(`${colors.gray}${status}${colors.reset}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const output = setElevationFromInput(store, raw);
|
||||||
const parts = raw.split(/\s+/);
|
console.log(`${colors.gray}${output}${colors.reset}\n`);
|
||||||
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 <duration> <reason...> --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 <duration> <reason...> --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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleModelCommand(name?: string, providerModel?: string): void {
|
private handleModelCommand(name?: string, providerModel?: string): void {
|
||||||
|
|||||||
+19
-101
@@ -16,7 +16,7 @@ import { MODEL_PROVIDERS } from '../../config/index.js';
|
|||||||
import { createClientFromConfig } from '../../daemon/models.js';
|
import { createClientFromConfig } from '../../daemon/models.js';
|
||||||
import { auditLogger } from '../../audit/index.js';
|
import { auditLogger } from '../../audit/index.js';
|
||||||
import type { HookEngine } from '../../hooks/index.js';
|
import type { HookEngine } from '../../hooks/index.js';
|
||||||
import { randomUUID } from 'crypto';
|
import { getElevationStatusMessage, setElevationFromInput } from '../../security/elevation.js';
|
||||||
|
|
||||||
export interface AgentHandlerDeps {
|
export interface AgentHandlerDeps {
|
||||||
sessionBridge: SessionBridge;
|
sessionBridge: SessionBridge;
|
||||||
@@ -391,117 +391,35 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
|||||||
if (!sessionId || !deps.sessionManager) {
|
if (!sessionId || !deps.sessionManager) {
|
||||||
return 'Elevated mode: off';
|
return 'Elevated mode: off';
|
||||||
}
|
}
|
||||||
const untilRaw = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.until_ms');
|
return getElevationStatusMessage({
|
||||||
const reason = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.reason') ?? '';
|
get: (key) => deps.sessionManager!.getSessionConfig('ws', sessionId, key),
|
||||||
const id = deps.sessionManager.getSessionConfig('ws', sessionId, 'elevation.id') ?? '';
|
set: (key, value) => deps.sessionManager!.setSessionConfig('ws', sessionId, key, value),
|
||||||
if (!untilRaw || !id) {
|
delete: (key) => deps.sessionManager!.deleteSessionConfig('ws', sessionId, key),
|
||||||
return 'Elevated mode: off';
|
}, {
|
||||||
}
|
showExpiredSuffix: true,
|
||||||
const untilMs = Number.parseInt(untilRaw, 10);
|
auditContext: {
|
||||||
if (!Number.isFinite(untilMs)) {
|
sessionId: `ws:${sessionId}`,
|
||||||
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}`,
|
|
||||||
channel: 'ws',
|
channel: 'ws',
|
||||||
sender: connectionId,
|
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) => {
|
setElevation: (input: string) => {
|
||||||
if (!sessionId || !deps.sessionManager) {
|
if (!sessionId || !deps.sessionManager) {
|
||||||
return 'Elevate command is not available in this session.';
|
return 'Elevate command is not available in this session.';
|
||||||
}
|
}
|
||||||
const raw = input.trim();
|
return setElevationFromInput({
|
||||||
const parts = raw.split(/\s+/);
|
get: (key) => deps.sessionManager!.getSessionConfig('ws', sessionId, key),
|
||||||
const hasYes = parts.includes('--yes') || parts.includes('--confirm');
|
set: (key, value) => deps.sessionManager!.setSessionConfig('ws', sessionId, key, value),
|
||||||
const filtered = parts.filter(p => p !== '--yes' && p !== '--confirm');
|
delete: (key) => deps.sessionManager!.deleteSessionConfig('ws', sessionId, key),
|
||||||
|
}, input, {
|
||||||
if (filtered.length === 0) {
|
auditContext: {
|
||||||
return 'Usage: /elevate <duration> <reason...> --yes | /elevate off --yes';
|
sessionId: `ws:${sessionId}`,
|
||||||
}
|
|
||||||
|
|
||||||
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}`,
|
|
||||||
channel: 'ws',
|
channel: 'ws',
|
||||||
sender: connectionId,
|
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 <duration> <reason...> --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: () => {
|
getQueue: () => {
|
||||||
|
|||||||
@@ -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