aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-09-29 15:56:27 +0200
committerAgando <roduccomm@gmail.com>2026-09-29 15:56:27 +0200
commitdde918a235ff8eac41f6d205e954dc4a4daa5b01 (patch)
treea58da7664e938d6d4d0fca0974932139e8b8584c
parent0e454aecf55d862b7618ebd1c98ca9fd71a15817 (diff)
newOS4 v4.6.15: reentrant print-capture stack, AI tail -f block, read-only sed in safeRead, security test harness (21 checks)
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js62
-rw-r--r--modules/core/module-aiassist.js33
-rw-r--r--modules/core/module-fs.js4
-rw-r--r--modules/core/terminal.js25
-rw-r--r--modules/ext/critical-ext-bundle.js20
-rw-r--r--modules/ext/shell.js20
-rw-r--r--scripts/test-security.mjs194
8 files changed, 303 insertions, 61 deletions
diff --git a/main.html b/main.html
index 73a64f1..4b79775 100644
--- a/main.html
+++ b/main.html
@@ -1345,13 +1345,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.14"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.15"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.14"></script>
+<script src="modules/core/module-loader.js?v=v4.6.15"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.14"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.15"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index c7a91dc..6127f36 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -2726,6 +2726,9 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
this._pipeInput = null;
this._pipeBufferSize = 5000;
this._activeFollow = null;
+ // Print-capture stack: [{output: ''}] — see print() head. LIFO order:
+ // nested/parallel captures each get their own buffer.
+ this._printCaptureStack = [];
this._historyBuffer = [];
this._historyMaxLines = 50;
this._historySavePending = false;
@@ -2783,6 +2786,15 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
_invalidateCommandCache() {
if (this.security) this.security.invalidateCommandCache();
}
+ _pushPrintCapture(onChunk) {
+ // onChunk(buffer) called at pop with the full captured text
+ if (!this._printCaptureStack) this._printCaptureStack = [];
+ this._printCaptureStack.push({ output: '', onChunk });
+ }
+ _popPrintCapture() {
+ const cap = this._printCaptureStack && this._printCaptureStack.pop();
+ if (cap && cap.onChunk) cap.onChunk(cap.output);
+ }
stopFollow(msg) {
if (!this._activeFollow) return false;
clearInterval(this._activeFollow.interval);
@@ -2968,6 +2980,14 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
label.textContent = prefix + (path === '/' ? '$' : path + ' $');
}
print(text, type = '') {
+ // Print-capture stack: while active, output goes to the innermost capture
+ // buffer instead of the DOM. Reentrant — parallel/nested captures each
+ // collect their own output (race-safe, replaces monkey-patched print).
+ if (this._printCaptureStack && this._printCaptureStack.length > 0) {
+ const cap = this._printCaptureStack[this._printCaptureStack.length - 1];
+ cap.output += text + '\n';
+ return;
+ }
if (['error','warning','success','info','text-muted'].includes(type) && !this._showSystemMessages && !type.startsWith('command')) {
// Still log to history buffer so it can be shown again when toggled on,
// but don't create DOM nodes while system messages are hidden.
@@ -3958,13 +3978,12 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
this._pipeInput = null;
}
async capturePipeOutput(cmd, args) {
- const originalPrint = this.print.bind(this);
let output = '';
- this.print = (text, type) => { output += text + '\n'; };
+ this._pushPrintCapture(buf => { output += buf; });
try {
await this.dispatchCommand(cmd, args);
} finally {
- this.print = originalPrint;
+ this._popPrintCapture();
}
const trimmed = output.trim();
if (trimmed.length > this._pipeBufferSize) {
@@ -6432,6 +6451,10 @@ class FileSystemModule {
if (c === null || c === undefined) return this.terminal.print('tail: ' + file + ': No such file', 'error');
this.terminal.print(c.split('\n').slice(-n).join('\n'));
if (!follow) return;
+ // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer
+ if (this.terminal.security && this.terminal.security._aiToolDepth > 0) {
+ return this.terminal.print('tail: follow mode (-f) is interactive-only; showing last lines instead (use tail -n ' + Math.max(n, 50) + ' for more)', 'warning');
+ }
// stop any previous follow before starting a new one
if (this.terminal._activeFollow && this.terminal._activeFollow.interval) clearInterval(this.terminal._activeFollow.interval);
this.terminal._activeFollow = null;
@@ -8937,24 +8960,22 @@ buildSystemPrompt() {
}
// ─── Self-Improvement Helpers ───
async _captureCommandOutput(cmd) {
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard: AI-initiated commands must not route back to AI on unknown command
this.terminal.security._aiToolDepth++;
+ this.terminal._pushPrintCapture(buf => { output += buf; });
try { await this.terminal.security.checkAndRun(cmd); }
- finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
return output.trim();
}
async _captureShellCommand(cmd) {
// Routes through executeCommand (shell parser) for >, |, &&, export, etc.
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard: AI-initiated commands must not route back to AI on unknown command
this.terminal.security._aiToolDepth++;
+ this.terminal._pushPrintCapture(buf => { output += buf; });
try { await this.terminal.executeCommand(cmd); }
- finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
return output.trim();
}
async _captureConsoleErrors(durationMs) {
@@ -10003,11 +10024,10 @@ buildSystemPrompt() {
this._snapshotShadowVFS();
const cmdName = cmd.split(' ')[0].toLowerCase();
const cmdArgs = cmd.split(' ').slice(1);
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
+ this.terminal._pushPrintCapture(buf => { output += buf; });
this.terminal.dispatchCommand(cmdName, cmdArgs).then(() => {
- this.terminal.print = originalPrint;
+ this.terminal._popPrintCapture();
const diff = this._diffShadowVFS(false);
if (!diff || diff.length === 0) {
this.terminal.print('📦 Shadow: No filesystem changes detected.', 'success');
@@ -10024,7 +10044,7 @@ buildSystemPrompt() {
this.terminal.print('🔒 VFS modified — type "shadow-commit" to accept or "shadow-rollback" to undo.', 'warning');
}
}).catch(e => {
- this.terminal.print = originalPrint;
+ this.terminal._popPrintCapture();
this.terminal.print('⚠️ Shadow command error: ' + e.message, 'error');
});
}
@@ -10052,8 +10072,11 @@ buildSystemPrompt() {
if (name === 'run_command') {
const cmdStr = typeof args === 'object' ? (args?.command || '') : String(args);
const cmd = cmdStr.split(' ')[0].toLowerCase();
+ // sed: read-only UNLESS -i (in-place write) or it starts a live tail -f timer
+ if (cmd === 'sed' && /(^|\s)-i(\s|$)/.test(cmdStr)) return true;
+ if (cmd === 'tail' && /(^|\s)-f\b|(^|\s)--follow\b/.test(cmdStr)) return false; // tail -f self-downgrades for AI (module-fs guard)
const safeRead = new Set(['ls','cat','pwd','echo','date','env','whoami','which','hostname',
- 'cal','tree','find','grep','head','tail','sort','wc','du','diff','basename','dirname',
+ 'cal','tree','find','grep','head','tail','sed','sort','wc','du','diff','basename','dirname',
'yes','seq','cut','uniq','expand','printf','base64','help','clear','set','export',
'unalias','history','ll','h','mem','status','df','free','top','ps','health-check',
'system-health','modules','listmods','modules-list','get-help-info','alias',
@@ -10239,10 +10262,9 @@ buildSystemPrompt() {
if (!kw) return JSON.stringify({ error: 'No keyword' });
const mod = this.terminal.moduleManager.modules['WpmModule'];
if (mod) {
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
- try { await mod.search(kw); } finally { this.terminal.print = originalPrint; }
+ this.terminal._pushPrintCapture(buf => { output += buf; });
+ try { await mod.search(kw); } finally { this.terminal._popPrintCapture(); }
return output.trim() || 'Search completed';
}
return JSON.stringify({ error: 'WPM module not available' });
@@ -10252,13 +10274,13 @@ buildSystemPrompt() {
if (!cmd) return JSON.stringify({ error: 'No command provided' });
// 📸 Shadow VFS auto-snapshot before run_command
this._snapshotShadowVFS();
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard (depth counter — race-safe under Promise.all tool fan-out):
// unknown commands return an error instead of routing back to AI
this.terminal.security._aiToolDepth++;
- try { await this.terminal.security.checkAndRun(cmd); } finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ this.terminal._pushPrintCapture(buf => { output += buf; });
+ try { await this.terminal.security.checkAndRun(cmd); }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
// Parse per-call captured output (race-free) — not shared security state
const nfMatch = output.match(/Command not found: ([^\s.]+)(?:\. Did you mean: ([^\s.?]+)\?)?/);
if (nfMatch) {
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 3d04df7..9c090ce 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -945,24 +945,22 @@ buildSystemPrompt() {
}
// ─── Self-Improvement Helpers ───
async _captureCommandOutput(cmd) {
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard: AI-initiated commands must not route back to AI on unknown command
this.terminal.security._aiToolDepth++;
+ this.terminal._pushPrintCapture(buf => { output += buf; });
try { await this.terminal.security.checkAndRun(cmd); }
- finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
return output.trim();
}
async _captureShellCommand(cmd) {
// Routes through executeCommand (shell parser) for >, |, &&, export, etc.
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard: AI-initiated commands must not route back to AI on unknown command
this.terminal.security._aiToolDepth++;
+ this.terminal._pushPrintCapture(buf => { output += buf; });
try { await this.terminal.executeCommand(cmd); }
- finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
return output.trim();
}
async _captureConsoleErrors(durationMs) {
@@ -2011,11 +2009,10 @@ buildSystemPrompt() {
this._snapshotShadowVFS();
const cmdName = cmd.split(' ')[0].toLowerCase();
const cmdArgs = cmd.split(' ').slice(1);
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
+ this.terminal._pushPrintCapture(buf => { output += buf; });
this.terminal.dispatchCommand(cmdName, cmdArgs).then(() => {
- this.terminal.print = originalPrint;
+ this.terminal._popPrintCapture();
const diff = this._diffShadowVFS(false);
if (!diff || diff.length === 0) {
this.terminal.print('📦 Shadow: No filesystem changes detected.', 'success');
@@ -2032,7 +2029,7 @@ buildSystemPrompt() {
this.terminal.print('🔒 VFS modified — type "shadow-commit" to accept or "shadow-rollback" to undo.', 'warning');
}
}).catch(e => {
- this.terminal.print = originalPrint;
+ this.terminal._popPrintCapture();
this.terminal.print('⚠️ Shadow command error: ' + e.message, 'error');
});
}
@@ -2060,8 +2057,11 @@ buildSystemPrompt() {
if (name === 'run_command') {
const cmdStr = typeof args === 'object' ? (args?.command || '') : String(args);
const cmd = cmdStr.split(' ')[0].toLowerCase();
+ // sed: read-only UNLESS -i (in-place write) or it starts a live tail -f timer
+ if (cmd === 'sed' && /(^|\s)-i(\s|$)/.test(cmdStr)) return true;
+ if (cmd === 'tail' && /(^|\s)-f\b|(^|\s)--follow\b/.test(cmdStr)) return false; // tail -f self-downgrades for AI (module-fs guard)
const safeRead = new Set(['ls','cat','pwd','echo','date','env','whoami','which','hostname',
- 'cal','tree','find','grep','head','tail','sort','wc','du','diff','basename','dirname',
+ 'cal','tree','find','grep','head','tail','sed','sort','wc','du','diff','basename','dirname',
'yes','seq','cut','uniq','expand','printf','base64','help','clear','set','export',
'unalias','history','ll','h','mem','status','df','free','top','ps','health-check',
'system-health','modules','listmods','modules-list','get-help-info','alias',
@@ -2247,10 +2247,9 @@ buildSystemPrompt() {
if (!kw) return JSON.stringify({ error: 'No keyword' });
const mod = this.terminal.moduleManager.modules['WpmModule'];
if (mod) {
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
- try { await mod.search(kw); } finally { this.terminal.print = originalPrint; }
+ this.terminal._pushPrintCapture(buf => { output += buf; });
+ try { await mod.search(kw); } finally { this.terminal._popPrintCapture(); }
return output.trim() || 'Search completed';
}
return JSON.stringify({ error: 'WPM module not available' });
@@ -2260,13 +2259,13 @@ buildSystemPrompt() {
if (!cmd) return JSON.stringify({ error: 'No command provided' });
// 📸 Shadow VFS auto-snapshot before run_command
this._snapshotShadowVFS();
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
// Loop guard (depth counter — race-safe under Promise.all tool fan-out):
// unknown commands return an error instead of routing back to AI
this.terminal.security._aiToolDepth++;
- try { await this.terminal.security.checkAndRun(cmd); } finally { this.terminal.security._aiToolDepth--; this.terminal.print = originalPrint; }
+ this.terminal._pushPrintCapture(buf => { output += buf; });
+ try { await this.terminal.security.checkAndRun(cmd); }
+ finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; }
// Parse per-call captured output (race-free) — not shared security state
const nfMatch = output.match(/Command not found: ([^\s.]+)(?:\. Did you mean: ([^\s.?]+)\?)?/);
if (nfMatch) {
diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js
index 112302b..d94feb8 100644
--- a/modules/core/module-fs.js
+++ b/modules/core/module-fs.js
@@ -256,6 +256,10 @@ class FileSystemModule {
if (c === null || c === undefined) return this.terminal.print('tail: ' + file + ': No such file', 'error');
this.terminal.print(c.split('\n').slice(-n).join('\n'));
if (!follow) return;
+ // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer
+ if (this.terminal.security && this.terminal.security._aiToolDepth > 0) {
+ return this.terminal.print('tail: follow mode (-f) is interactive-only; showing last lines instead (use tail -n ' + Math.max(n, 50) + ' for more)', 'warning');
+ }
// stop any previous follow before starting a new one
if (this.terminal._activeFollow && this.terminal._activeFollow.interval) clearInterval(this.terminal._activeFollow.interval);
this.terminal._activeFollow = null;
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index 3b28b52..123c224 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -300,6 +300,9 @@
this._pipeInput = null;
this._pipeBufferSize = 5000;
this._activeFollow = null;
+ // Print-capture stack: [{output: ''}] — see print() head. LIFO order:
+ // nested/parallel captures each get their own buffer.
+ this._printCaptureStack = [];
this._historyBuffer = [];
this._historyMaxLines = 50;
this._historySavePending = false;
@@ -357,6 +360,15 @@
_invalidateCommandCache() {
if (this.security) this.security.invalidateCommandCache();
}
+ _pushPrintCapture(onChunk) {
+ // onChunk(buffer) called at pop with the full captured text
+ if (!this._printCaptureStack) this._printCaptureStack = [];
+ this._printCaptureStack.push({ output: '', onChunk });
+ }
+ _popPrintCapture() {
+ const cap = this._printCaptureStack && this._printCaptureStack.pop();
+ if (cap && cap.onChunk) cap.onChunk(cap.output);
+ }
stopFollow(msg) {
if (!this._activeFollow) return false;
clearInterval(this._activeFollow.interval);
@@ -542,6 +554,14 @@
label.textContent = prefix + (path === '/' ? '$' : path + ' $');
}
print(text, type = '') {
+ // Print-capture stack: while active, output goes to the innermost capture
+ // buffer instead of the DOM. Reentrant — parallel/nested captures each
+ // collect their own output (race-safe, replaces monkey-patched print).
+ if (this._printCaptureStack && this._printCaptureStack.length > 0) {
+ const cap = this._printCaptureStack[this._printCaptureStack.length - 1];
+ cap.output += text + '\n';
+ return;
+ }
if (['error','warning','success','info','text-muted'].includes(type) && !this._showSystemMessages && !type.startsWith('command')) {
// Still log to history buffer so it can be shown again when toggled on,
// but don't create DOM nodes while system messages are hidden.
@@ -1532,13 +1552,12 @@
this._pipeInput = null;
}
async capturePipeOutput(cmd, args) {
- const originalPrint = this.print.bind(this);
let output = '';
- this.print = (text, type) => { output += text + '\n'; };
+ this._pushPrintCapture(buf => { output += buf; });
try {
await this.dispatchCommand(cmd, args);
} finally {
- this.print = originalPrint;
+ this._popPrintCapture();
}
const trimmed = output.trim();
if (trimmed.length > this._pipeBufferSize) {
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index 2230498..8773f47 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -502,9 +502,8 @@ class ShellModule {
}
// > or >> — capture output, write to file
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
+ this.terminal._pushPrintCapture(buf => { output += buf; });
let cmdOk = true;
try {
@@ -522,13 +521,12 @@ class ShellModule {
fs.writeFile('__pipebuf__', pipeInput);
}
if (i < pipeParts.length - 1) {
- const captPrint = this.terminal.print.bind(this.terminal);
let capt = '';
- this.terminal.print = (t, ty) => { capt += t + '\n'; };
+ this.terminal._pushPrintCapture(buf => { capt += buf; });
try {
await this.terminal.dispatchCommand(pCmd, pArgs);
} finally {
- this.terminal.print = captPrint;
+ this.terminal._popPrintCapture();
}
pipeInput = capt.trim();
} else {
@@ -546,14 +544,18 @@ class ShellModule {
}
} catch(e) {
cmdOk = false;
- // Restore print before showing the error
- this.terminal.print = originalPrint;
+ // Pop capture before showing the error
+ this.terminal._popPrintCapture();
this.terminal.print('Error: ' + e.message, 'error');
redirectInfo.exitOk = false;
return;
} finally {
- // Always restore print — critical: without this, terminal is permanently broken
- this.terminal.print = originalPrint;
+ // Always pop capture — critical: without this, terminal output stays captured
+ // (empty stack = normal DOM printing, so pop is idempotent-safe when already popped)
+ if (this.terminal._printCaptureStack && this.terminal._printCaptureStack.length > 0) {
+ // popped in catch path for error case; success path pops here
+ this.terminal._popPrintCapture();
+ }
}
const captured = output.trimEnd();
diff --git a/modules/ext/shell.js b/modules/ext/shell.js
index 80bc841..9966e0f 100644
--- a/modules/ext/shell.js
+++ b/modules/ext/shell.js
@@ -366,9 +366,8 @@ class ShellModule {
}
// > or >> — capture output, write to file
- const originalPrint = this.terminal.print.bind(this.terminal);
let output = '';
- this.terminal.print = (text, type) => { output += text + '\n'; };
+ this.terminal._pushPrintCapture(buf => { output += buf; });
let cmdOk = true;
try {
@@ -386,13 +385,12 @@ class ShellModule {
fs.writeFile('__pipebuf__', pipeInput);
}
if (i < pipeParts.length - 1) {
- const captPrint = this.terminal.print.bind(this.terminal);
let capt = '';
- this.terminal.print = (t, ty) => { capt += t + '\n'; };
+ this.terminal._pushPrintCapture(buf => { capt += buf; });
try {
await this.terminal.dispatchCommand(pCmd, pArgs);
} finally {
- this.terminal.print = captPrint;
+ this.terminal._popPrintCapture();
}
pipeInput = capt.trim();
} else {
@@ -410,14 +408,18 @@ class ShellModule {
}
} catch(e) {
cmdOk = false;
- // Restore print before showing the error
- this.terminal.print = originalPrint;
+ // Pop capture before showing the error
+ this.terminal._popPrintCapture();
this.terminal.print('Error: ' + e.message, 'error');
redirectInfo.exitOk = false;
return;
} finally {
- // Always restore print — critical: without this, terminal is permanently broken
- this.terminal.print = originalPrint;
+ // Always pop capture — critical: without this, terminal output stays captured
+ // (empty stack = normal DOM printing, so pop is idempotent-safe when already popped)
+ if (this.terminal._printCaptureStack && this.terminal._printCaptureStack.length > 0) {
+ // popped in catch path for error case; success path pops here
+ this.terminal._popPrintCapture();
+ }
}
const captured = output.trimEnd();
diff --git a/scripts/test-security.mjs b/scripts/test-security.mjs
new file mode 100644
index 0000000..f94a4fe
--- /dev/null
+++ b/scripts/test-security.mjs
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+// Test harness for newOS4 WebX11SecurityManager (checkAndRun, suggest, levels, AI loop guard).
+// Loads the security + terminal class source in a vm sandbox with a stub terminal.
+// Run: node scripts/test-security.mjs (exit 1 on failure)
+
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const SRC = readFileSync(path.join(ROOT, 'modules/core/terminal.js'), 'utf8');
+
+function makeCtx() {
+ const ctx = {
+ setInterval, clearInterval, setTimeout, clearTimeout, console,
+ localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
+ document: {
+ createElement: () => ({ style: {}, appendChild: () => {}, classList: { add: () => {} }, setAttribute: () => {}, addEventListener: () => {} }),
+ getElementById: () => null,
+ addEventListener: () => {},
+ },
+ window: {},
+ };
+ ctx.window = ctx;
+ vm.createContext(ctx);
+ // terminal.js defines classes inside an IIFE/module scope — expose them for the test
+ vm.runInContext(SRC + '\n;globalThis.__WebX11SecurityManager = (typeof WebX11SecurityManager !== "undefined") ? WebX11SecurityManager : null;', ctx, { filename: 'terminal.js' });
+ return ctx;
+}
+
+let pass = 0, fail = 0;
+const check = (name, cond, got) => {
+ if (cond) { pass++; console.log(' ok ' + name); }
+ else { fail++; console.log(' FAIL ' + name + (got !== undefined ? ' — got: ' + JSON.stringify(got) : '')); }
+};
+
+const ctx = makeCtx();
+if (!ctx.__WebX11SecurityManager) {
+ console.error('WebX11SecurityManager not reachable at module scope — class is file-scoped.');
+ console.error('Check how terminal.js wraps the class and expose it for tests.');
+ process.exit(2);
+}
+
+// Stub terminal: capture print calls, provide the module registry shape security expects
+function makeStubTerminal() {
+ const out = [];
+ return {
+ out,
+ print: (t, ty) => out.push({ text: String(t), type: ty || '' }),
+ moduleManager: { modules: {
+ // A module with commands so _collectAllCommands finds them (tail needed for typo tests)
+ 'FsModule': { commands: { 'ls': () => {}, 'cat': () => {}, 'grep': () => {}, 'tail': () => {}, 'head': () => {}, 'sed': () => {}, 'sort': () => {} } },
+ } },
+ security: null, // set by caller
+ showConfirmationBar: null,
+ showSuggestionBar: null,
+ _tryAIRoute: null,
+ _dispatch: null,
+ };
+}
+
+function makeSec(level, stubTerminal) {
+ const sec = new ctx.__WebX11SecurityManager(stubTerminal, level);
+ stubTerminal.security = sec;
+ return sec;
+}
+
+const text = (out) => out.map(o => o.text).join('\n');
+
+// ─── construction & base state ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ check('constructs at level 1', sec.level === 1);
+ check('depth counter starts at 0', sec._aiToolDepth === 0);
+ check('suggest finds tail for taill', JSON.stringify(sec.suggest('taill')) === '{"cmd":"tail","dist":1}', sec.suggest('taill'));
+ check('suggest null for gibberish', sec.suggest('zzzzzzzzz') === null);
+}
+
+// ─── level 2: unknown → blocked, no AI ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(2, t);
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ sec._dispatch = () => {};
+ await sec.checkAndRun('foobarxyz');
+ check('L2 unknown blocked message', text(t.out).includes('blocked (level 2)'), text(t.out));
+ check('L2 unknown does not route to AI', aiRouted === false);
+}
+
+// ─── level 1 + AI depth: unknown → structured error, no bar, no AI route ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ sec._aiToolDepth = 1; // simulate AI tool call
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ sec._dispatch = () => {};
+ await sec.checkAndRun('foobarxyz');
+ check('AI-depth unknown returns "Command not found"', text(t.out).includes('Command not found: foobarxyz'), text(t.out));
+ check('AI-depth unknown does not route to AI', aiRouted === false);
+
+ const t2 = makeStubTerminal();
+ const sec2 = makeSec(1, t2);
+ sec2._aiToolDepth = 2; // nested depth
+ let aiRouted2 = false;
+ sec2._tryAIRoute = () => { aiRouted2 = true; };
+ sec2._dispatch = () => {};
+ await sec2.checkAndRun('taill x.txt');
+ check('nested AI-depth typo returns error + suggestion', text(t2.out).includes('Command not found: taill') && text(t2.out).includes('tail'), text(t2.out));
+ check('nested AI-depth does not show suggestion bar', !t2.out.some(o => o.text.includes('did you mean:')), t2.out.map(o => o.text));
+ check('nested AI-depth does not route to AI', aiRouted2 === false);
+}
+
+// ─── level 1 user path: typo → suggestion bar (stub has no showSuggestionBar → fallback message) ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ sec._dispatch = () => {};
+ // stub terminal lacks showSuggestionBar → code takes AI-route fallback... verify current guard
+ await sec.checkAndRun('taill x.txt');
+ // With showSuggestionBar missing, we expect routing to AI (bar unavailable)
+ check('user typo without bar API routes to AI', aiRouted === true);
+}
+
+// ─── level 1 user path with bar API: typo → bar, no AI ───
+{
+ const t = makeStubTerminal();
+ t.showSuggestionBar = (cmd, args) => { t.barShown = { cmd, args }; };
+ const sec = makeSec(1, t);
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ sec._dispatch = () => {};
+ await sec.checkAndRun('taill x.txt');
+ check('user typo with bar API shows bar', t.barShown && t.barShown.cmd === 'tail', t.barShown);
+ check('user typo with bar API does not route to AI', aiRouted === false);
+}
+
+// ─── level 1 user path: real unknown → AI route ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ sec._dispatch = () => {};
+ await sec.checkAndRun('foobarxyz');
+ check('user real-unknown routes to AI', aiRouted === true);
+ check('user real-unknown message says routing', text(t.out).includes('routing to AI'), text(t.out));
+}
+
+// ─── known whitelisted command dispatches normally ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ let dispatched = null;
+ sec._dispatch = (cmd, args) => { dispatched = { cmd, args }; };
+ await sec.checkAndRun('sed s/a/b/ file.txt');
+ check('whitelisted sed dispatches', dispatched && dispatched.cmd === 'sed', dispatched);
+ check('no security messages for known cmd', t.out.length === 0, t.out);
+}
+
+// ─── AI depth does not leak across sequential calls ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(1, t);
+ sec._tryAIRoute = () => {};
+ sec._dispatch = () => {};
+ sec._aiToolDepth = 1;
+ await sec.checkAndRun('nope1');
+ check('depth still 1 after first call', sec._aiToolDepth === 1);
+ sec._aiToolDepth = 0;
+ let aiRouted = false;
+ sec._tryAIRoute = () => { aiRouted = true; };
+ await sec.checkAndRun('nope2');
+ check('depth 0 → routes to AI again', aiRouted === true);
+}
+
+// ─── L2 blocked message with suggestion ───
+{
+ const t = makeStubTerminal();
+ const sec = makeSec(2, t);
+ sec._tryAIRoute = () => {};
+ sec._dispatch = () => {};
+ await sec.checkAndRun('taill x.txt');
+ check('L2 block includes did-you-mean', text(t.out).includes('Did you mean: "tail"?'), text(t.out));
+}
+
+console.log('');
+console.log(`${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0); \ No newline at end of file