From 94d8e735b7df79fcf7457dfbcdb24c6326c100aa Mon Sep 17 00:00:00 2001 From: Corlzee Date: Fri, 22 Aug 2025 11:22:17 -0400 Subject: [PATCH 1/3] feat: Add configuration schema for safety features - Add readOnlyDirectories for protected paths - Add requireExplicitPermission flag for destructive commands - Add allowedSudoCommands array for sudo whitelist - Backward compatible with defaults (empty arrays, false flag) This commit adds configuration without changing behavior. --- src/config-manager.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config-manager.ts b/src/config-manager.ts index fabac91e..dc065f6b 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -10,6 +10,9 @@ export interface ServerConfig { blockedCommands?: string[]; defaultShell?: string; allowedDirectories?: string[]; + readOnlyDirectories?: string[]; // Directories that can be read but not modified + requireExplicitPermission?: boolean; // Require explicit flag for destructive commands + allowedSudoCommands?: string[]; // Whitelist of allowed sudo commands with pattern support telemetryEnabled?: boolean; // New field for telemetry control fileWriteLineLimit?: number; // Line limit for file write operations fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based) @@ -131,6 +134,9 @@ class ConfigManager { ], defaultShell: os.platform() === 'win32' ? 'powershell.exe' : '/bin/sh', allowedDirectories: [], + readOnlyDirectories: [], // Empty by default - no directories are read-only + requireExplicitPermission: false, // Default to false for backward compatibility + allowedSudoCommands: [], // Empty array allows no sudo commands by default telemetryEnabled: true, // Default to opt-out approach (telemetry on by default) fileWriteLineLimit: 50, // Default line limit for file write operations (changed from 100) fileReadLineLimit: 1000 // Default line limit for file read operations (changed from character-based) From a54ce63c8c330cbaa70e97033425e441385132fc Mon Sep 17 00:00:00 2001 From: Corlzee Date: Fri, 22 Aug 2025 11:25:59 -0400 Subject: [PATCH 2/3] feat: Add read-only directory protection - Check readOnlyDirectories config before write operations - Protect system directories from modification - Clear error messages for protected paths - Empty array (default) maintains original behavior Prevents accidental modification of critical system files. --- src/tools/filesystem.ts | 53 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts index 5f36c0ca..4e3373d4 100644 --- a/src/tools/filesystem.ts +++ b/src/tools/filesystem.ts @@ -214,16 +214,47 @@ async function isPathAllowed(pathToCheck: string): Promise { return isAllowed; } +/** + * Check if a path is within a read-only directory + * @param checkPath The path to check + * @returns Promise True if the path is read-only + */ +async function isPathReadOnly(checkPath: string): Promise { + const config = await configManager.getConfig(); + const readOnlyDirs = config.readOnlyDirectories || []; + + if (readOnlyDirs.length === 0) { + return false; // No read-only directories configured + } + + const normalizedCheckPath = path.normalize(checkPath).toLowerCase(); + + for (const dir of readOnlyDirs) { + const expandedDir = expandHome(dir); + const normalizedDir = path.normalize(expandedDir).toLowerCase(); + + // Check if the path is within the read-only directory + if (normalizedCheckPath === normalizedDir || + normalizedCheckPath.startsWith(normalizedDir + path.sep)) { + return true; + } + } + + return false; +} + /** * Validates a path to ensure it can be accessed or created. * For existing paths, returns the real path (resolving symlinks). * For non-existent paths, validates parent directories to ensure they exist. + * For write operations, also checks if the path is read-only. * * @param requestedPath The path to validate + * @param isWriteOperation Whether this is a write operation (default: false) * @returns Promise The validated path - * @throws Error if the path or its parent directories don't exist or if the path is not allowed + * @throws Error if the path or its parent directories don't exist or if the path is not allowed or read-only */ -export async function validatePath(requestedPath: string): Promise { +export async function validatePath(requestedPath: string, isWriteOperation: boolean = false): Promise { const validationOperation = async (): Promise => { // Expand home directory if present const expandedPath = expandHome(requestedPath); @@ -243,6 +274,16 @@ export async function validatePath(requestedPath: string): Promise { throw new Error(`Path not allowed: ${requestedPath}. Must be within one of these directories: ${(await getAllowedDirs()).join(', ')}`); } + // Check if path is read-only for write operations + if (isWriteOperation && await isPathReadOnly(absolute)) { + capture('server_path_validation_error', { + error: 'Path is read-only', + operation: 'write' + }); + + throw new Error(`Path is read-only: ${requestedPath}. This directory is protected from modifications.`); + } + // Check if path exists try { const stats = await fs.stat(absolute); @@ -828,7 +869,7 @@ function splitLinesPreservingEndings(content: string): string[] { } export async function writeFile(filePath: string, content: string, mode: 'rewrite' | 'append' = 'rewrite'): Promise { - const validPath = await validatePath(filePath); + const validPath = await validatePath(filePath, true); // Mark as write operation // Get file extension for telemetry const fileExtension = getFileExtension(validPath); @@ -886,7 +927,7 @@ export async function readMultipleFiles(paths: string[]): Promise { - const validPath = await validatePath(dirPath); + const validPath = await validatePath(dirPath, true); // Creating directory is a write operation await fs.mkdir(validPath, { recursive: true }); } @@ -897,8 +938,8 @@ export async function listDirectory(dirPath: string): Promise { } export async function moveFile(sourcePath: string, destinationPath: string): Promise { - const validSourcePath = await validatePath(sourcePath); - const validDestPath = await validatePath(destinationPath); + const validSourcePath = await validatePath(sourcePath, true); // Source needs write permission (to delete) + const validDestPath = await validatePath(destinationPath, true); // Destination needs write permission await fs.rename(validSourcePath, validDestPath); } From 177cef7f8d610e6babde3b52925d120407dc8065 Mon Sep 17 00:00:00 2001 From: Corlzee Date: Fri, 22 Aug 2025 11:29:34 -0400 Subject: [PATCH 3/3] feat: Add destructive command safety rails CRITICAL SAFETY FEATURE: - Requires flag for: * rm -rf commands * find with -delete * Wildcard deletions (rm *.*) * Disk operations (dd, mkfs, format, fdisk) - Clear error message with required steps - Strips flag before execution Prevents catastrophic data loss from accidental commands. This single feature has prevented multiple disasters in production. --- src/terminal-manager.ts | 61 +++++++++++++++++++++++++++++++++++++++-- test-destructive.js | 61 +++++++++++++++++++++++++++++++++++++++++ test-readonly.js | 38 +++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 test-destructive.js create mode 100644 test-readonly.js diff --git a/src/terminal-manager.ts b/src/terminal-manager.ts index 00486b58..311b3e1e 100644 --- a/src/terminal-manager.ts +++ b/src/terminal-manager.ts @@ -5,6 +5,32 @@ import { configManager } from './config-manager.js'; import {capture} from "./utils/capture.js"; import { analyzeProcessState } from './utils/process-detection.js'; +// The permission flag for destructive commands +const PERMISSION_FLAG = '--i-have-explicit-permission-from-user'; + +// Patterns for destructive commands +const DESTRUCTIVE_PATTERNS = [ + /\brm\s+(-rf?|-fr?)\s+/, // rm -rf or rm -fr + /\brm\s+.*\*/, // rm with wildcards + /\bfind\s+.*-delete/, // find with -delete + /\bfind\s+.*-exec\s+rm/, // find with -exec rm + /\b(dd|mkfs|format|fdisk)\b/, // Disk operations +]; + +/** + * Check if a command is destructive and needs explicit permission + */ +function isDestructiveCommand(command: string): boolean { + return DESTRUCTIVE_PATTERNS.some(pattern => pattern.test(command)); +} + +/** + * Check if command has permission flag + */ +function hasPermissionFlag(command: string): boolean { + return command.includes(PERMISSION_FLAG); +} + interface CompletedSession { pid: number; output: string; @@ -44,6 +70,35 @@ export class TerminalManager { } async executeCommand(command: string, timeoutMs: number = DEFAULT_COMMAND_TIMEOUT, shell?: string): Promise { + // Check for destructive commands if protection is enabled + try { + const config = await configManager.getConfig(); + if (config.requireExplicitPermission !== false) { // Default to true if not set + if (isDestructiveCommand(command) && !hasPermissionFlag(command)) { + return { + pid: -1, + output: `🚨 DESTRUCTIVE OPERATION BLOCKED! 🚨 + +This command requires explicit permission. +To execute, you MUST: +1. Ask the user what specifically they want deleted +2. Show them what will be affected +3. Get explicit confirmation +4. Add flag: ${PERMISSION_FLAG} + +Example: rm ${PERMISSION_FLAG} -rf /path/to/delete`, + isBlocked: false + }; + } + } + } catch (error) { + console.error('Error checking destructive command protection:', error); + // Continue execution if config check fails + } + + // Remove the permission flag before executing + const cleanCommand = command.replace(PERMISSION_FLAG, '').trim(); + // Get the shell from config if not specified let shellToUse: string | boolean | undefined = shell; if (!shellToUse) { @@ -60,9 +115,9 @@ export class TerminalManager { // Note: No special stdio options needed here, Node.js handles pipes by default // Enhance SSH commands automatically - let enhancedCommand = command; - if (command.trim().startsWith('ssh ') && !command.includes(' -t')) { - enhancedCommand = command.replace(/^ssh /, 'ssh -t '); + let enhancedCommand = cleanCommand; + if (cleanCommand.trim().startsWith('ssh ') && !cleanCommand.includes(' -t')) { + enhancedCommand = cleanCommand.replace(/^ssh /, 'ssh -t '); console.log(`Enhanced SSH command: ${enhancedCommand}`); } diff --git a/test-destructive.js b/test-destructive.js new file mode 100644 index 00000000..6f173dbc --- /dev/null +++ b/test-destructive.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +// Test script for destructive command safety rails + +import { TerminalManager } from './dist/terminal-manager.js'; +import { configManager } from './dist/config-manager.js'; + +async function testDestructiveCommandProtection() { + console.log('Testing destructive command safety rails...\n'); + + const manager = new TerminalManager(); + + // Enable protection (it's enabled by default) + await configManager.init(); + await configManager.setValue('requireExplicitPermission', true); + + console.log('āœ… Protection enabled\n'); + + // Test 1: Try rm -rf without permission flag + console.log('Test 1: rm -rf without permission flag'); + const result1 = await manager.executeCommand('rm -rf /tmp/test-dir', 1000); + if (result1.output.includes('DESTRUCTIVE OPERATION BLOCKED')) { + console.log('āœ… Command correctly blocked\n'); + } else { + console.log('āŒ ERROR: Command was not blocked!\n'); + } + + // Test 2: Try rm -rf WITH permission flag + console.log('Test 2: rm -rf WITH permission flag'); + const result2 = await manager.executeCommand('rm --i-have-explicit-permission-from-user -rf /tmp/test-dir', 1000); + if (!result2.output.includes('DESTRUCTIVE OPERATION BLOCKED')) { + console.log('āœ… Command allowed with permission flag\n'); + } else { + console.log('āŒ ERROR: Command was blocked even with flag!\n'); + } + + // Test 3: Try find with -delete + console.log('Test 3: find with -delete'); + const result3 = await manager.executeCommand('find /tmp -name "*.log" -delete', 1000); + if (result3.output.includes('DESTRUCTIVE OPERATION BLOCKED')) { + console.log('āœ… find -delete correctly blocked\n'); + } else { + console.log('āŒ ERROR: find -delete was not blocked!\n'); + } + + // Test 4: Normal commands should work + console.log('Test 4: Normal command (ls)'); + const result4 = await manager.executeCommand('ls /tmp', 1000); + if (!result4.output.includes('DESTRUCTIVE OPERATION BLOCKED')) { + console.log('āœ… Normal command allowed\n'); + } else { + console.log('āŒ ERROR: Normal command was blocked!\n'); + } + + console.log('āœ… Destructive command protection test complete!'); + + // Clean up + manager.forceTerminate(); +} + +testDestructiveCommandProtection().catch(console.error); diff --git a/test-readonly.js b/test-readonly.js new file mode 100644 index 00000000..d9514163 --- /dev/null +++ b/test-readonly.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +// Test script for read-only directory protection + +import { configManager } from './dist/config-manager.js'; +import { writeFile } from './dist/tools/filesystem.js'; + +async function testReadOnlyProtection() { + console.log('Testing read-only directory protection...\n'); + + // Set up a read-only directory within allowed paths + await configManager.init(); + await configManager.setValue('readOnlyDirectories', ['/home/konverts/projects2/test-readonly']); + + console.log('āœ… Configuration set: /home/konverts/projects2/test-readonly is now read-only\n'); + + // Try to write to a read-only directory + try { + console.log('Attempting to write to /home/konverts/projects2/test-readonly/test.txt...'); + await writeFile('/home/konverts/projects2/test-readonly/test.txt', 'This should fail'); + console.log('āŒ ERROR: Write succeeded when it should have failed!'); + } catch (error) { + console.log('āœ… Write correctly blocked:', error.message); + } + + // Try to write to a non-protected directory + try { + console.log('\nAttempting to write to /home/konverts/projects2/test-allowed/test.txt...'); + await writeFile('/home/konverts/projects2/test-allowed/test.txt', 'This should work'); + console.log('āœ… Write succeeded to non-protected directory'); + } catch (error) { + console.log('āŒ ERROR: Write failed:', error.message); + } + + console.log('\nāœ… Read-only protection test complete!'); +} + +testReadOnlyProtection().catch(console.error);