diff options
Diffstat (limited to 'modules/core/terminal.js')
| -rw-r--r-- | modules/core/terminal.js | 452 |
1 files changed, 338 insertions, 114 deletions
diff --git a/modules/core/terminal.js b/modules/core/terminal.js index ee1afa4..2617f79 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -1,4 +1,184 @@ // Extracted from newOS4 index.html — WebX11Terminal + class WebX11SecurityManager { + constructor(terminal, level = 1) { + this.terminal = terminal; + this.level = level; + this.commandMeta = new Map(); + this.lastRouteTime = 0; + this.lastRouteCmd = ''; + this.blockedCount = 0; + this.destructiveCmds = new Set(['rm','delete','del','edit','write','save','overwrite','format','kill','stop','shutdown','reboot']); + this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']); + this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']); + this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']); + this._registerBuiltIn(); + } + _registerBuiltIn() { + const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','startup-modules','smart-prompt','cmd-allow','cmd-deny']; + internals.forEach(c => this.registerCommand(c, { category: 'builtin' })); + } + setLevel(n) { + if (n >= 0 && n <= 3) { + this.level = n; + this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success'); + if (n === 0) { + this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning'); + } + } + else { this.terminal.print('Level must be 0-3', 'error'); } + } + getLevel() { return this.level; } + getLevelName() { + return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown'; + } + registerCommand(name, meta = {}) { + this.commandMeta.set(name, { destructive: false, category: 'user', ...meta }); + } + _collectAllCommands() { + const cmds = new Set([...this.commandMeta.keys()]); + for (const modName in this.terminal.moduleManager.modules) { + const mod = this.terminal.moduleManager.modules[modName]; + if (mod.commands) { + for (const c in mod.commands) cmds.add(c); + } + } + const builtins = ['clear','help','ls','df','security','security-level']; + builtins.forEach(c => cmds.add(c)); + return [...cmds]; + } + _levenshtein(a, b) { + a = a.toLowerCase(); b = b.toLowerCase(); + const m = a.length, n = b.length; + if (!m) return n; if (!n) return m; + let prev = new Uint16Array(n + 1); + let curr = new Uint16Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + for (let i = 1; i <= m; i++) { + let next = new Uint16Array(n + 1); + next[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + next[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, next]; + } + return prev[n]; + } + suggest(input, maxDist = 2) { + const all = this._collectAllCommands(); + const lower = input.toLowerCase(); + let best = '', bestDist = Infinity; + for (const c of all) { + const d = this._levenshtein(lower, c); + if (d < bestDist && d <= maxDist) { bestDist = d; best = c; } + } + return best ? { cmd: best, dist: bestDist } : null; + } + _classify(raw) { + const parts = raw.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Internal commands always allowed at terminal level, but security still classifies + const all = this._collectAllCommands(); + const known = all.includes(cmd); + const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase())); + const destructive = this.destructiveCmds.has(cmd) || hasSuspicious; + const system = this.systemCmds.has(cmd); + const unknown = !known; + return { cmd, args, known, destructive, system, unknown, hasSuspicious }; + } + async checkAndRun(rawInput) { + const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); + // Level 3 Audit: just log and allow + if (this.level === 3) { + this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info'); + return this._dispatch(cmd, args); + } + // Always allow builtins that are safe, regardless of level + if (['help','security','security-level'].includes(cmd)) { + return this._dispatch(cmd, args); + } + // Unknown command handling + if (unknown && !this.whitelist.has(cmd)) { + this.blockedCount++; + const sug = this.suggest(cmd); + if (sug) { + this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning'); + } else { + this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning'); + } + // At levels 0-1: suggest/fix but still route to AI (terminal is also a chat interface) + // At level 2+: block entirely (paranoid) + if (this.level >= 2) { + this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error'); + return; + } + if (this.level >= 1) { + if (sug) { + this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning'); + } else { + this.terminal.print(`Security: Routing to AI...`, 'info'); + } + } + return this._tryAIRoute(rawInput); + } + // Suspicious flags (e.g. --execute) at any level > 0 + if (hasSuspicious && this.level > 0) { + this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error'); + this.blockedCount++; + return; + } + // Destructive commands + if (destructive) { + if (this.level === 0) { + this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning'); + return this._dispatch(cmd, args); + } + if (this.level >= 1) { + // Reuse your existing confirm UI if present, otherwise print confirm message + if (this.terminal.showConfirmationBar) { + this.terminal._pendingCommand = () => this._dispatch(cmd, args); + this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`); + return; + } else { + this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning'); + return; + } + } + } + // System commands at level >=1 + if (system && this.level >= 1) { + this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error'); + this.blockedCount++; + return; + } + // Standard dispatch for everything else + return this._dispatch(cmd, args); + } + async _dispatch(cmd, args) { + return this.terminal.dispatchCommand(cmd, args); + } + async _tryAIRoute(rawInput) { + // AI routing cooldown guard + const now = Date.now(); + if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) { + this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning'); + return; + } + this.lastRouteCmd = rawInput; + this.lastRouteTime = now; + try { + const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.terminal.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([rawInput.trim()]); + } + } catch (e) { + this.terminal.print(`Security: AI error: ${e.message}`, 'error'); + } + } + } + class WebX11Terminal { constructor() { this.outputContainer = document.getElementById('terminal-container'); @@ -15,6 +195,7 @@ } } catch (e) { console.warn('SecretVault init:', e.message); } this.moduleManager = new ModuleManager(this); + this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1); this.ollamaManager = new OllamaManager(this); this._pipeInput = null; this._pipeBufferSize = 5000; @@ -267,6 +448,53 @@ this.outputContainer.appendChild(line); this.scrollToBottom(); } + showConfirmationBar(desc) { + const area = document.getElementById('ai-confirm-area'); + if (!area) { + this.print('Confirmation UI unavailable — command blocked.', 'error'); + return; + } + this._pendingCommandDesc = desc; + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${desc}</span> + <button class="confirm-btn" id="cmd-confirm-allow" onclick="(function(){var a=document.getElementById('cmd-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="cmd-confirm-deny" onclick="(function(){var d=document.getElementById('cmd-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: cmd-allow or cmd-deny</span> + `; + area.classList.add('visible'); + const allowBtn = document.getElementById('cmd-confirm-allow'); + const denyBtn = document.getElementById('cmd-confirm-deny'); + const onAllow = () => { + this._resolveCmdConfirm(true); + }; + const onDeny = () => { + this._resolveCmdConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + } + _resolveCmdConfirm(approved) { + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + if (approved) { + this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success'); + if (this._pendingCommand) { + const fn = this._pendingCommand; + this._pendingCommand = null; + this._pendingCommandDesc = null; + fn(); + } + } else { + this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning'); + this._pendingCommand = null; + this._pendingCommandDesc = null; + } + } _loadHistory() { try { const saved = localStorage.getItem('webx11_terminal_output'); @@ -331,7 +559,7 @@ setTimeout(() => { this._historySavePending = false; try { - const slice = this._historyBuffer.slice(-30); + const slice = this._historyBuffer.slice(-this._historyMaxLines); localStorage.setItem('webx11_terminal_output', JSON.stringify(slice)); } catch (e) { // Silently ignore storage errors (e.g. quota exceeded) @@ -425,6 +653,55 @@ scrollToBottom() { this.outputContainer.scrollTop = this.outputContainer.scrollHeight; } + _getPathSuggestions(partial, type) { + // type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json + if (type === 'session') { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + const names = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')); + return partial + ? names.filter(n => n.startsWith(partial)).map(n => n) + : names; + } + // Resolve the partial path to determine which directory to list + let dirPath = ''; + let filterPrefix = ''; + let entries = []; + let prefix = ''; + + if (partial.includes('/')) { + // "dir1/dir2" or "dir1/dir2/" or "dir1/" + const lastSlash = partial.lastIndexOf('/'); + const beforeSlash = partial.slice(0, lastSlash); // dir1 + filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /) + // Resolve "dir1" relative to current path + const resolvedDir = this.fs._resolveDir(beforeSlash || '.'); + if (!resolvedDir) return []; + entries = Object.keys(resolvedDir.content).map(name => ({ + name, + type: resolvedDir.content[name].type, + size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0 + })); + prefix = beforeSlash + '/'; + } else { + // No slash — list current dir + entries = this.fs.ls(); + filterPrefix = partial; + prefix = ''; + } + + if (type === 'dir') { + return entries + .filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix)) + .map(e => prefix + e.name + '/'); + } + // 'file' — show files + dirs (dirs with trailing /) + return entries + .filter(e => e.name.startsWith(filterPrefix)) + .map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name); + } showAutocomplete() { const input = this.inputField.value; const trimmed = input.trim(); @@ -442,11 +719,24 @@ const cmdPart = parts[0].toLowerCase(); const lowerInput = input.toLowerCase(); - // Commands that take file/dir arguments for autocomplete - const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; - const dirArgCommands = ['ls', 'cd']; - // Session commands that autocomplete from _sessions/*.json - const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; + // Expanded command lists for autocomplete + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list']; + const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir']; + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import']; + const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; + + // Collect commands that take arguments from ext module metadata + const extFileCmds = new Set(); + const extDirCmds = new Set(); + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commandsMeta) { + for (const [c, meta] of Object.entries(mod.commandsMeta)) { + if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c); + if (meta.argType === 'dir') extDirCmds.add(c); + } + } + } // If we have more than one word, we're past the command name — autocomplete file/dir/session names if (parts.length > 1 && parts[1].length > 0) { @@ -458,25 +748,13 @@ return; } - // Determine if we should show session names, dirs only, or both if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')) - .filter(n => n.startsWith(partialTrimmed)); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - // Dir-only commands - const entries = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = entries - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.type === 'dir' ? e.name + '/' : e.name); + this.suggestions = this._getPathSuggestions(partialTrimmed, 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); } else { this.hideAutocomplete(); return; @@ -484,7 +762,6 @@ this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -495,24 +772,18 @@ // Just typed a space after command — show all matching entries if (parts.length > 1) { if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); + this.suggestions = this._getPathSuggestions('', 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'file'); } else { this.hideAutocomplete(); return; } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -520,10 +791,9 @@ return; } - // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block + // Directory autocomplete for bare 'cd ' (no partial yet) if (lowerInput.startsWith('cd ')) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); + this.suggestions = this._getPathSuggestions('', 'dir'); if (this.suggestions.length > 0) { this._isCdAutocomplete = true; this.renderAutocomplete(); @@ -623,15 +893,17 @@ catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } } async _loadExtModules(filterKeys) { - const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; + const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'; try { - const res = await fetch('packages.json'); + const res = await fetch('packages.json?v=' + cacheVer); if (!res.ok) throw new Error(`HTTP ${res.status}`); const index = await res.json(); for (const key of autoLoadKeys) { const pkg = index.packages[key]; if (pkg) { - await this.moduleManager.loadModuleFromURL(pkg.url); + const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer; + await this.moduleManager.loadModuleFromURL(url); } } } catch (err) { @@ -640,7 +912,7 @@ } async reloadPersistedModules() { const registry = this.moduleManager.moduleRegistry; - const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; for (const mod of registry) { if (mod.enabled && mod.url) { // Skip ext modules — they load via _loadExtModules @@ -659,65 +931,8 @@ await this.executePipeChain(pipeParts); return; } - const parts = rawInput.trim().split(/\s+/); - const cmd = parts[0].toLowerCase(); - const args = parts.slice(1); - if (cmd === 'startup-modules') { - this.openStartupModules(); - return; - } - if (cmd === 'font-size' || cmd === 'setfont') { - this.setFontSize(args[0]); - return; - } - if (cmd === 'load-local' || cmd === 'loadlocal') { - this.loadLocalModule(args[0]); - return; - } - if (cmd === 'smart-prompt') { - const val = args[0] && args[0] === 'off' ? 'off' : 'on'; - localStorage.setItem('webx11_smart_prompt', val); - this.print('Smart prompt ' + val, 'success'); - this.updatePrompt(); - return; - } - if (cmd === 'mem') { - this.printMemoryStats(); - return; - } - if (cmd === 'clear') { - this.outputContainer.innerHTML = ''; - this._historyBuffer = []; - localStorage.removeItem('webx11_terminal_output'); - return; - } - let executed = false; - for (const modName in this.moduleManager.modules) { - const mod = this.moduleManager.modules[modName]; - if (mod.commands && mod.commands[cmd]) { - try { - await mod.commands[cmd](args); - executed = true; - } catch (err) { - this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); - } - break; - } - } - if (!executed) { - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→', 'info'); - await aiMod.chat([rawInput.trim()]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - return; - } - } + // Security gate for all single commands and internal builtins + await this.security.checkAndRun(rawInput); } splitPipe(input) { const parts = []; @@ -761,6 +976,25 @@ } async dispatchCommand(cmd, args) { if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + if (cmd === 'startup-modules') { this.openStartupModules(); return; } + if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; } + if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; } + if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; } + if (cmd === 'mem') { this.printMemoryStats(); return; } + if (cmd === 'security') { + this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info'); + this.print(`Blocked commands: ${this.security.blockedCount}`, 'info'); + return; + } + if (cmd === 'security-level') { + const lv = parseInt(args[0]); + if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; } + this.security.setLevel(lv); + localStorage.setItem('webx11_security_level', lv); + return; + } + if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; } + if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; } for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commands && mod.commands[cmd]) { @@ -768,17 +1002,7 @@ catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } } } - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→ Routing to AI assistant...', 'info'); - await aiMod.chat([cmd + ' ' + args.join(' ')]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - } + // No AI routing here — security manager handles that via checkAndRun this.print(`Command not found: ${cmd}`, 'error'); } openFileBrowser() { |