diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 17:53:39 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 17:53:39 +0200 |
| commit | 9ae724e87a6471ce503783615ef6083d991531ce (patch) | |
| tree | da5523a5c8d3550c4236ead87cf878067a9cedbe | |
| parent | 83e64b88910c952c7469cee57cc5c5a8a07ad902 (diff) | |
fix(newos4): refuse shell composition, wc takes multiple paths (v4.6.58)
Packet 1 and 3 (silent wrong answers from a shell-less terminal).
- detectComposition() in the security gate refuses ; && || | before dispatch.
`;` used to fail loudly, but `&&`/`||` folded the right-hand side into the left
command's argv (wc -l f && grep -c x f returned "42 42"), and a `|` arriving from
the AI run_command tool bypassed splitPipe entirely and degraded the pattern to
empty -- which matches every line. Operators inside quotes are untouched.
- wc accepts multiple paths: one line per file plus a total, per-file errors for
missing paths. `wc -c a b` used to print only `a` and exit 0.
- help.html: "There is no shell" callout at the top of the command reference,
plus the v4.6.57/58 changelog entry.
Verified live on v4.6.58 by replaying the exact call sequence used by both paths:
interactive `wc -c a b` -> "14 a / 10 b / 24 total"; `&&` and `;` refused;
`echo "a && b"` still echoes; AI run_command semantics with `|` and `&&` refused;
single command and multi-file wc still return real output.
| -rw-r--r-- | help.html | 23 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 74 | ||||
| -rw-r--r-- | modules/core/module-fs.js | 48 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/terminal.js | 26 |
6 files changed, 147 insertions, 32 deletions
@@ -484,6 +484,18 @@ diff a.txt b.txt</pre> <section id="commands"> <h2>๐ Full Command Reference</h2> + <div class="callout"> + <strong>โ ๏ธ There is no shell.</strong> The terminal splits your line into argv and hands it to + one command โ it does not interpret <code>;</code>, <code>&&</code>, <code>||</code> or + redirection. Those are now <em>refused</em> with an explicit message instead of being folded + into the command's arguments (which used to produce a plausible wrong answer): + <code>wc -l a && grep -c x a</code> โ refused; run two commands. + A single <code>cmd | cmd</code> pipeline works in the interactive terminal (not in the AI + <code>run_command</code> tool). <code>wc</code>, <code>grep</code> and friends take + <strong>one path per call</strong> unless documented otherwise โ <code>wc -c a b</code> now + prints a line per file plus a <code>total</code>. + </div> + <h3>System</h3> <div class="table-wrap"><table> <tr><th>Command</th><th>Description</th><th>Example</th></tr> @@ -1660,6 +1672,17 @@ listmods</pre> <h2>๐ Changelog</h2> <div class="version-item"> + <div class="v-header"><span class="v-num">v4.6.57/58 โ October 2026</span><span class="v-date">2026-10-23</span></div> + <ul> + <li><strong>No shell โ and it now says so</strong> โ <code>;</code>, <code>&&</code> and <code>||</code> are refused with an explicit message instead of being folded into the command's argv (where <code>wc -l f && grep -c x f</code> returned a plausible wrong number). A <code>|</code> arriving from the AI <code>run_command</code> tool is refused too, because that path bypasses the pipe splitter and degraded the pattern to empty. Documented at the top of the command reference.</li> + <li><strong>Multiple paths in <code>wc</code></strong> โ <code>wc -c a b</code> printed only <code>a</code> and exited clean. It now prints one line per file plus a <code>total</code>, and reports each missing file separately.</li> + <li><strong><code>ls</code> prints text</strong> โ it rendered only clickable chips (DOM-only), so any capture โ the AI <code>run_command</code> tool, the diagnostics sweep โ saw an empty result and reported "directory empty".</li> + <li><strong><code>view_module</code> shows what is deployed</strong> โ it returned the <code>_mods/<Class>.js</code> edit cache first, unversioned, so after a rebuild it showed pre-fix source while the bundle was new. It now fetches the deployed file and prefixes the version; the cache is a labelled fallback.</li> + <li><strong>Predictor arithmetic</strong> โ the stored field named <code>mape</code> held a sum, so any JSON consumer read a mean that was <em>n</em> times too large (now <code>errorSum</code>); <code>errorPct</code> is stored raw and clamped only at render (a 475ร miss used to be flattened to 500%); predictions are floored at 1 s and floor-hits are not scored, because one 0.079 s aborted run was rounding the next prediction to 0 s.</li> + </ul> + </div> + + <div class="version-item"> <div class="v-header"><span class="v-num">v4.6.56 โ October 2026</span><span class="v-date">2026-10-23</span></div> <ul> <li><strong>Predictor accuracy was structurally 0%</strong> โ the denominator counted every observed run while the numerator only moved when a <em>stored prediction</em> matched, and predictions were stored only by the manual <code>predict</code> command. Unpaired runs no longer enter the accuracy denominator; the AI loop now registers a prediction per turn (<code>notePrediction</code>) and <code>predict_task</code> returns a tracked <code>predictionId</code> instead of discarding its estimate.</li> @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.57"></script> +<script src="modules/core/core-bundle.js?v=v4.6.58"></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.57"></script> +<script src="modules/core/module-loader.js?v=v4.6.58"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.57"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.58"></script> <script> // โโโ ONE TRUE VERSION SOURCE โโโ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index c654021..941707e 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -2628,7 +2628,33 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm const unknown = !known; return { cmd, args, known, destructive, system, unknown, hasSuspicious }; } + // v4.6.58: this terminal has no shell. `;` at least failed loudly, but `&&`/`||` + // folded the right-hand side into the left command's argv, and a `|` arriving from + // the AI run_command tool (which bypasses splitPipe) degraded to an empty pattern + // matching every line. Both are silent wrong answers โ refuse instead (packet ยง1). + detectComposition(input) { + const s = String(input || ''); + let inSingle = false, inDouble = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (inSingle || inDouble) continue; + if (ch === '&' && s[i + 1] === '&') return '&&'; + if (ch === '|' && s[i + 1] === '|') return '||'; + if (ch === '|') return '|'; + if (ch === ';') return ';'; + } + return null; + } async checkAndRun(rawInput) { + const badOp = this.detectComposition(rawInput); + if (badOp) { + this.terminal.print('โ ๏ธ No shell here โ `' + badOp + '` is not interpreted. ' + + 'Run one command per line (the interactive terminal supports `cmd | cmd`; ' + + 'AI run_command does not).', 'error'); + return; + } const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); // Level 3 Audit: just log and allow if (this.level === 3) { @@ -6766,26 +6792,46 @@ class FileSystemModule { this.commands['wc'] = (args) => { // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, // so `wc -l file` answered "wc: -l: No such file"). + // v4.6.58: multiple files. `wc -c a b` used to print only `a` and exit clean โ + // a silent false negative (packet ยง1). const want = { l: false, w: false, c: false }; - let file = null; + const files = []; (args || []).forEach(a => { if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } - else if (!file) file = a; + else files.push(a); }); - if (!file) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename>', 'warning'); - const content = this.terminal.fs.readFile(file); - if (content === null || content === undefined) return this.terminal.print('wc: ' + file + ': No such file', 'error'); - const lines = content.split('\n').length; - const words = content.split(/\s+/).filter(w => w).length; - const chars = content.length; + if (!files.length) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); const anyFlag = want.l || want.w || want.c; - const out = []; - if (want.l || !anyFlag) out.push(lines); - if (want.w || !anyFlag) out.push(words); - if (want.c || !anyFlag) out.push(chars); - if (anyFlag && !(want.l && want.w && want.c)) out.push(file); - this.terminal.print(out.join(' ')); + const rows = []; + let total = { l: 0, w: 0, c: 0 }; + files.forEach(file => { + const content = this.terminal.fs.readFile(file); + if (content === null || content === undefined) { + this.terminal.print('wc: ' + file + ': No such file', 'error'); + return; + } + const r = { + file: file, + l: content.split('\n').length, + w: content.split(/\s+/).filter(w => w).length, + c: content.length + }; + total.l += r.l; total.w += r.w; total.c += r.c; + rows.push(r); + }); + if (!rows.length) return; + const multi = rows.length > 1; + const fmt = (r, name) => { + const out = []; + if (want.l || !anyFlag) out.push(r.l); + if (want.w || !anyFlag) out.push(r.w); + if (want.c || !anyFlag) out.push(r.c); + if (multi || (anyFlag && !(want.l && want.w && want.c))) out.push(name); + return out.join(' '); + }; + rows.forEach(r => this.terminal.print(fmt(r, r.file))); + if (multi) this.terminal.print(fmt(total, 'total')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index 03c3733..f93553a 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -438,26 +438,46 @@ class FileSystemModule { this.commands['wc'] = (args) => { // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, // so `wc -l file` answered "wc: -l: No such file"). + // v4.6.58: multiple files. `wc -c a b` used to print only `a` and exit clean โ + // a silent false negative (packet ยง1). const want = { l: false, w: false, c: false }; - let file = null; + const files = []; (args || []).forEach(a => { if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } - else if (!file) file = a; + else files.push(a); }); - if (!file) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename>', 'warning'); - const content = this.terminal.fs.readFile(file); - if (content === null || content === undefined) return this.terminal.print('wc: ' + file + ': No such file', 'error'); - const lines = content.split('\n').length; - const words = content.split(/\s+/).filter(w => w).length; - const chars = content.length; + if (!files.length) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); const anyFlag = want.l || want.w || want.c; - const out = []; - if (want.l || !anyFlag) out.push(lines); - if (want.w || !anyFlag) out.push(words); - if (want.c || !anyFlag) out.push(chars); - if (anyFlag && !(want.l && want.w && want.c)) out.push(file); - this.terminal.print(out.join(' ')); + const rows = []; + let total = { l: 0, w: 0, c: 0 }; + files.forEach(file => { + const content = this.terminal.fs.readFile(file); + if (content === null || content === undefined) { + this.terminal.print('wc: ' + file + ': No such file', 'error'); + return; + } + const r = { + file: file, + l: content.split('\n').length, + w: content.split(/\s+/).filter(w => w).length, + c: content.length + }; + total.l += r.l; total.w += r.w; total.c += r.c; + rows.push(r); + }); + if (!rows.length) return; + const multi = rows.length > 1; + const fmt = (r, name) => { + const out = []; + if (want.l || !anyFlag) out.push(r.l); + if (want.w || !anyFlag) out.push(r.w); + if (want.c || !anyFlag) out.push(r.c); + if (multi || (anyFlag && !(want.l && want.w && want.c))) out.push(name); + return out.join(' '); + }; + rows.forEach(r => this.terminal.print(fmt(r, r.file))); + if (multi) this.terminal.print(fmt(total, 'total')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index b31fd83..0708228 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.57'; + const LATEST_VER = 'v4.6.58'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/core/terminal.js b/modules/core/terminal.js index ad4b105..10608d1 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -176,7 +176,33 @@ const unknown = !known; return { cmd, args, known, destructive, system, unknown, hasSuspicious }; } + // v4.6.58: this terminal has no shell. `;` at least failed loudly, but `&&`/`||` + // folded the right-hand side into the left command's argv, and a `|` arriving from + // the AI run_command tool (which bypasses splitPipe) degraded to an empty pattern + // matching every line. Both are silent wrong answers โ refuse instead (packet ยง1). + detectComposition(input) { + const s = String(input || ''); + let inSingle = false, inDouble = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (inSingle || inDouble) continue; + if (ch === '&' && s[i + 1] === '&') return '&&'; + if (ch === '|' && s[i + 1] === '|') return '||'; + if (ch === '|') return '|'; + if (ch === ';') return ';'; + } + return null; + } async checkAndRun(rawInput) { + const badOp = this.detectComposition(rawInput); + if (badOp) { + this.terminal.print('โ ๏ธ No shell here โ `' + badOp + '` is not interpreted. ' + + 'Run one command per line (the interactive terminal supports `cmd | cmd`; ' + + 'AI run_command does not).', 'error'); + return; + } const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); // Level 3 Audit: just log and allow if (this.level === 3) { |