From c099458de9faec4fe54baa357f6b56a25344c828 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 16 Jul 2026 09:25:07 -0700 Subject: [PATCH] fix(statusline): overlay ruflo's fabricated CVE counter with the real scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statusline told every clean repo it had 3 CVEs. Upstream's getSecurityStatus (@claude-flow/cli funnel/local-signals.js) hardcodes `const totalCves = 3` — ruflo's OWN v3 roadmap items (CVE-1/2/3 in their v3-security-architect.md: an outdated dep + SHA-256 hashing + hardcoded creds in THEIR api/auth-service.ts), not the rendered project's risk — and derives cvesFixed from scans.length, a FILE count. The alarm therefore cleared itself: run the suggested scan, it writes a JSON file, the counter drops. Three files reach CLEAN with nothing scanned, let alone fixed, while a real finding never registers. Reported upstream: ruvnet/ruflo#2694. The overlay reports what the newest scan actually found and never invents a CVE: totalCves/cvesFixed are pinned to 0 so the "N CVEs" branch cannot fire, and real state rides in `status`, which ruflo's renderer prints verbatim — PENDING when never scanned (an honest unknown, not a false green), "N ISSUES" when the scan found things, CLEAN, or STALE past 7 days. Wired by wrapping getStatuslineData rather than patching applyLocalOverlays: the fresh-cache early return (`if (cache.fresh && cache.promoFresh) return overlayMemoPromo(cache.data)`) bypasses applyLocalOverlays entirely, so a patch there is never called during the 60s TTL and the fabricated count renders anyway. Wrapping the one entry point covers all four return paths. The count also reaches the screen a second way — funnel/insights.js computes `pending = totalCves - cvesFixed` CLI-side and ships a finished sentence — so rufloHonestInsight rebuilds that one line from the real scan, matching on text because promo.js discards the insight id. Gated on positively detecting the defect in the installed CLI rather than a pinned version, mirroring improvement-eval's --cli-check stopgap: the patch retires itself on the first sync after upstream fixes this. Covered by a test. Also in this change: - status: detect statusline CONTENT drift, not just marker presence. Drift is now "would a sync change this file?", which fixStatusline's dry run already answers. A marker test reported 'ok' on a stale injected block, and since sync builds its plan from rows carrying a `fix`, re-injection never ran. This bit us live: an updated overlay silently failed to land. It also means any kit upgrade revising the footer would not have re-injected. - statusline: make the aidefence segment alarm-only. It was a permanent green "aidefence on" — the one pure binary badge in the footer (SONA/QE counts move; a constant "on" says nothing after the first glance) — and its shield glyph collided with ruflo's line-2 scan shield, a different concern entirely: `security scan` audits your source, `security defend`/AIMDS screens prompts for injection, jailbreak and PII. Per issue #8's rule, already law for the proof segment, the expected state is now silent and only the failure surfaces, with no shield glyph in the alarm. Kept rather than deleted because it is load-bearing: @claude-flow/aidefence is still not a declared dependency of ruflo or @claude-flow/cli on 3.32.0 while `security defend` imports it (ruvnet/ruflo#2670), so it is present only because healAidefence installs it. A plain `npm i -g ruflo` can silently remove injection defense. The probe is three-state, not boolean: inverting a signal inverts its failure mode, and a probe miss (custom npm prefix, statusline running under a different node) would otherwise fail loud and WRONG. "off" requires positive evidence — a located ruflo install lacking aidefence; anything unverifiable stays "unknown" and silent. --- src/commands/status.mjs | 34 +++- src/lib/statusline.mjs | 65 +++++++- src/templates/statusline-footer.cjs | 162 +++++++++++++++++-- tests/kit/statusline.test.mjs | 106 +++++++++++++ tests/statusline-segments.test.cjs | 234 +++++++++++++++++++++++++++- 5 files changed, 582 insertions(+), 19 deletions(-) create mode 100644 tests/kit/statusline.test.mjs diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 2aae4fd8..de029373 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -12,6 +12,7 @@ import { scanRvf } from '../lib/rvf.mjs'; import { registry, syncBlocks } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; +import { upstreamCveCounterFabricated, fixStatusline } from '../lib/statusline.mjs'; import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; @@ -261,10 +262,35 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { // statusline footer (project scope) const sl = paths.projectStatusline(cwd); if (fs.existsSync(sl)) { - const hasFooter = fs.readFileSync(sl, 'utf8').includes('ruflo-seg:BEGIN'); - rows.push(row('statusline', hasFooter ? 'ok' : 'warn', - hasFooter ? 'activation footer present' : 'statusline present but footer missing', - hasFooter ? null : 'sync re-injects the footer')); + const slSrc = fs.readFileSync(sl, 'utf8'); + const hasFooter = slSrc.includes('ruflo-seg:BEGIN'); + // Drift is "would a sync CHANGE this file?", which fixStatusline's dry run answers + // exactly. A marker-presence test alone cannot see CONTENT drift: after a kit upgrade + // revises the footer or the security overlay, the marker is still there, this row + // reports 'ok', and — because sync builds its plan from rows carrying a `fix` — the + // re-injection never runs and the stale block survives indefinitely. Observed live: + // an updated overlay silently failed to land for exactly this reason. + let wouldChange = !hasFooter; + try { wouldChange = fixStatusline(cwd, { dryRun: true }).applied; } catch { /* keep marker fallback */ } + rows.push(row('statusline', wouldChange ? 'warn' : 'ok', + wouldChange + ? (hasFooter ? 'injected blocks are out of date' : 'statusline present but footer missing') + : 'activation footer present and current', + wouldChange ? 'sync re-injects the footer' : null)); + // The CVE-counter overlay is tracked SEPARATELY from the footer: a footer-only + // check reports 'ok' while the statusline still renders ruflo's fabricated + // "⚠ 3 CVEs" (hardcoded totalCves, cvesFixed from a file count). Only warn while + // the upstream defect is actually present — once ruflo fixes getSecurityStatus + // the overlay is intentionally absent, and this row must go quiet on its own + // rather than nag for a patch that is no longer wanted. + if (upstreamCveCounterFabricated()) { + const patched = slSrc.includes('ruflo-sec:BEGIN'); + rows.push(row('statusline/cve', patched ? 'ok' : 'warn', + patched + ? 'CVE counter overlaid with real scan results' + : 'statusline shows ruflo\'s fabricated CVE count (upstream defect)', + patched ? null : 'sync injects the security overlay')); + } } else { rows.push(row('statusline', 'info', 'no project statusline here (created by setup)')); } diff --git a/src/lib/statusline.mjs b/src/lib/statusline.mjs index be999abf..418f5c15 100644 --- a/src/lib/statusline.mjs +++ b/src/lib/statusline.mjs @@ -9,7 +9,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; -import { projectStatusline, projectSettings } from './paths.mjs'; +import { projectStatusline, projectSettings, rufloCliDist } from './paths.mjs'; import { installedVersion } from './versions.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; @@ -18,6 +18,61 @@ const FOOTER_TEMPLATE = path.join( const eol = (s) => (s.includes('\r\n') ? '\r\n' : '\n'); +// Security overlay wrapper. Wraps getStatuslineData() rather than patching +// applyLocalOverlays(), because applyLocalOverlays is NOT on every path: the +// fresh-cache early return (`if (cache.fresh && cache.promoFresh) return +// overlayMemoPromo(cache.data)`) bypasses it, so for the 60s TTL a patched +// applyLocalOverlays is simply never called and the fabricated count renders +// anyway (verified empirically — the overlay had no effect until this wrapper). +// Wrapping the single entry point covers all four return paths (CLI delegation, +// fresh cache, stale-while-revalidate, local fallback) with one injection. +// +// Relies on function-declaration hoisting: `function getStatuslineData()` is +// initialized before any top-level code runs, so this block — injected near the +// top of the file — can reassign the binding, and the later declaration does not +// re-execute and clobber it. The typeof guard keeps it inert on any template that +// lacks the function (e.g. the minimal statusline-v3.cjs). +const SEC_WRAP = [ + '/* ruflo-sec:BEGIN */', + 'try {', + ' if (typeof getStatuslineData === "function") {', + ' var _rufloOrigGetStatuslineData = getStatuslineData;', + ' getStatuslineData = function(){', + ' var d = _rufloOrigGetStatuslineData.apply(this, arguments);', + ' try {', + ' if (d) {', + ' d.security = rufloLocalSecurity(process.cwd(), d.security);', + ' d.promo = rufloHonestInsight(d.promo, d.security);', + ' }', + ' } catch(e){}', + ' return d;', + ' };', + ' }', + '} catch(e){}', + '/* ruflo-sec:END */', +].join('\n'); +const SEC_WRAP_STRIP = /\/\* ruflo-sec:BEGIN \*\/[\s\S]*?\/\* ruflo-sec:END \*\/\n?/g; + +/** Upstream defect: ruvnet/ruflo#2694. + * True while ruflo's getSecurityStatus() still FABRICATES the CVE count — i.e. the + * installed CLI still has `const totalCves = 3` (a hardcoded constant naming ruflo's + * own v3 roadmap items, not the rendered project's risk) with cvesFixed derived from + * scans.length (a FILE count, not findings). Read-only probe of the installed CLI. + * + * This is the stopgap's self-retirement gate, mirroring improvement-eval's --cli-check + * (#2222): detect the defect in shipped code rather than pinning a version number, so + * the kit stops patching the moment upstream fixes it — no release-tracking required. + * Unreadable/absent/changed => false (fail safe: never patch what we cannot verify is + * broken; the worst case is ruflo's own unmodified behavior). */ +export function upstreamCveCounterFabricated() { + try { + const f = path.join(rufloCliDist(), 'funnel', 'local-signals.js'); + if (!fs.existsSync(f)) return false; + const src = fs.readFileSync(f, 'utf8'); + return /const totalCves = 3\b/.test(src) && /scans\.length/.test(src); + } catch { return false; } +} + export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { const file = projectStatusline(root); if (!fs.existsSync(file)) return { file, applied: false, reason: 'no statusline.cjs (created by ruflo init)' }; @@ -35,9 +90,13 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { const footer = fs.readFileSync(FOOTER_TEMPLATE, 'utf8').replace(/\r\n/g, '\n').trim(); s = s.replace(/\/\* ruflo-seg:BEGIN \*\/[\s\S]*?\/\* ruflo-seg:END \*\/\n?/, ''); s = s.replace(/ \+ rufloActivationSegments\(process\.cwd\(\)\)/g, ''); + // (d) security overlay: stripped unconditionally BEFORE the gate is consulted, so the + // stopgap retires itself on the first sync after upstream fixes getSecurityStatus. + s = s.replace(SEC_WRAP_STRIP, ''); + const securityOverlay = upstreamCveCounterFabricated(); const lines = s.split('\n'); const at = lines[0]?.startsWith('#!') ? 1 : 0; - lines.splice(at, 0, footer); + lines.splice(at, 0, securityOverlay ? footer + '\n' + SEC_WRAP : footer); s = lines.join('\n'); s = s.replace(/console\.log\(generateStatusline\(\)\)/, 'console.log(generateStatusline() + rufloActivationSegments(process.cwd()))'); @@ -71,5 +130,5 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) { repointed = true; } - return { file, applied: out !== raw, repointed, version: ver }; + return { file, applied: out !== raw, repointed, version: ver, securityOverlay }; } diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 73b7ddd5..84322fa3 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -2,6 +2,7 @@ function rufloActivationSegments(cwd){ try { var fs = require("fs"), path = require("path"), cp = require("child_process"); + var RED = "\x1b[1;31m"; // alarm-only segments (aidefence OFF) — matches ruflo's own brightRed var DIM = "", G = "", Y = "", C = "", R = ""; // execFileSync (no shell) — db path / sql are passed as argv, never interpolated into a command line. function q(db, sql){ try { return cp.execFileSync("sqlite3", [db, sql], {stdio:["ignore","pipe","ignore"], timeout:1500}).toString().trim(); } catch(e){ return ""; } } @@ -186,16 +187,41 @@ function rufloActivationSegments(cwd){ } } } catch(e){} - // ── security: 🛡 renders ONLY when @claude-flow/aidefence (the actual runtime - // defense engine behind `security defend`) is resolvable. ruflo 3.28 dropped it - // from the dependency tree while the command still imports it (ruvnet/ruflo#2670), - // so a bare 3.28 install has NO working injection defense — the segment honestly - // disappears until ruflo-resync reinstalls the package (@claude-flow/security is - // auth/validation primitives, not detection; probing it would overstate). + // ── AI defense (AIMDS) — ALARM-ONLY: renders only when it is MISSING ──────── + // Was a permanent green "🛡 aidefence on". Two reasons it inverted: + // 1. Issue #8's rule, already law for the proof segment below: the expected state + // is rendered SILENTLY, only a regression surfaces, no static green badge. A + // constant "on" carries no information after the first glance — unlike SONA/QE, + // whose counts move — so it was the one pure binary badge in this footer. + // 2. Glyph collision: ruflo's line 2 uses 🛡 for the SCAN state, a different + // concern entirely (`security scan` audits your SOURCE; this is `security + // defend` / AIMDS screening PROMPTS for injection, jailbreak and PII). Two + // shields meaning different things read as one duplicated thing. The alarm + // carries no 🛡 at all, so it can never be confused with the scan shield. + // + // Still load-bearing, not decoration: @claude-flow/aidefence is NOT a declared + // dependency of ruflo or @claude-flow/cli (verified still true on 3.32.0) while + // `security defend` imports it (ruvnet/ruflo#2670). It is present ONLY because the + // kit's healAidefence npm-installs it into rufloRoot(). A plain `npm i -g ruflo` + // can therefore silently remove your injection defense — and under the old polarity + // that catastrophe was signalled by a line quietly VANISHING, which is ambiguous + // (off? probe threw? forgot to look?). Now the dangerous state is the loud one. + // + // FAIL-SAFE POLARITY (the reason for the two-step probe): alarm only on POSITIVE + // evidence of absence — we located a ruflo install AND aidefence is not inside it. + // If ruflo itself cannot be found (custom npm prefix, or the statusline running + // under a different node than the one that installed it), we cannot know, so we say + // NOTHING. Inverting a signal also inverts its failure mode: a probe miss used to + // fail silent, and would now fail LOUD and WRONG. Claiming "your defense is off" + // when it is on is the same crime as the fabricated CVE counter overlaid above. + // Probe + verdict live in rufloAidefenceState/rufloFindRufloRoot (below) so the + // three-state logic is unit-testable against fixture trees — it cannot be exercised + // from here, where it depends on the real process.execPath. var sec = ""; try { - var nmBase = path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "ruflo", "node_modules", "@claude-flow"); - if (fs.existsSync(path.join(nmBase, "aidefence", "package.json"))) sec = G + "🛡 aidefence on" + R; + if (rufloAidefenceState(rufloFindRufloRoot()) === "off") { + sec = RED + "⚠ aidefence OFF" + R + DIM + " — no prompt-injection defense · ak sync restores it" + R; + } } catch(e){} // ── daemon visibility (⚙): GLOBAL count of running ruflo daemons, so no daemon // is ever invisible (token-burn incident lesson). Machine-global, not per-project, @@ -282,7 +308,9 @@ function rufloActivationSegments(cwd){ } } catch(e){} // ── assemble: one ruflo feature per line (SONA, 📈 RL, ◷ proof FAIL alarm, - // aidefence), then a divider, then the agentic-qe line. Each segment renders on its + // ⚠ aidefence OFF alarm), then a divider, then the agentic-qe line. The two alarms + // are silent in the healthy case, so a well-configured machine shows only the live + // metrics. Each segment renders on its // OWN line so the live route metrics and the security state are individually scannable // and don't wrap. No rule above the SONA line — these are ruflo features and sit flush // under ruflo's native lines. The divider matches ruflo's native header width @@ -299,4 +327,120 @@ function rufloActivationSegments(cwd){ return "\n" + out.join("\n"); } catch(e){ return ""; } } +// ── AI-defense probe (companion to the alarm-only segment above) ───────────── +// Locates the global ruflo install WITHOUT spawning npm (this runs on every render). +// Returns "" when no candidate resolves — the caller must treat that as "unknown", +// never as "off". +function rufloFindRufloRoot(){ + try { + var fs = require("fs"), path = require("path"), os = require("os"); + var binDir = path.dirname(process.execPath); + var cands = [ + path.join(binDir, "..", "lib", "node_modules", "ruflo"), // nvm / mise layout + path.join(binDir, "node_modules", "ruflo"), // Windows layout + ]; + // A custom npm prefix (~/.npm-global, npm_config_prefix) is decoupled from the node + // binary, so the execPath-derived probes above all miss it — same gap as upstream #2221. + var prefixes = [process.env.npm_config_prefix, process.env.PREFIX, path.join(os.homedir(), ".npm-global")]; + for (var pi = 0; pi < prefixes.length; pi++) { + if (prefixes[pi]) cands.push(path.join(prefixes[pi], "lib", "node_modules", "ruflo")); + } + for (var ci = 0; ci < cands.length; ci++) { + if (fs.existsSync(path.join(cands[ci], "package.json"))) return cands[ci]; + } + return ""; + } catch(e){ return ""; } +} +// Three states, not two — the distinction IS the fail-safe. "off" is asserted only on +// positive evidence: a real ruflo install that does not contain aidefence. Anything we +// cannot verify is "unknown" and stays silent, because a false "your injection defense +// is off" would be exactly the fabricated-alarm bug this footer exists to correct. +// @claude-flow/security is auth/validation primitives, not detection — probing it +// instead would overstate, so only aidefence counts. +function rufloAidefenceState(rufloRoot){ + try { + var fs = require("fs"), path = require("path"); + if (!rufloRoot || !fs.existsSync(path.join(rufloRoot, "package.json"))) return "unknown"; + var ad = path.join(rufloRoot, "node_modules", "@claude-flow", "aidefence", "package.json"); + return fs.existsSync(ad) ? "on" : "off"; + } catch(e){ return "unknown"; } +} +// ── security overlay: replaces ruflo's FABRICATED CVE counter with the real scan ── +// Upstream (@claude-flow/cli dist/src/funnel/local-signals.js, getSecurityStatus) does: +// let cvesFixed = 0; const totalCves = 3; +// cvesFixed = Math.min(totalCves, scans.length); // counts FILES, not findings +// Two independent defects. (1) `totalCves = 3` is a hardcoded constant referring to +// ruflo's OWN v3 remediation roadmap — CVE-1/2/3 in .claude/agents/v3/v3-security-architect.md +// are an outdated @anthropic-ai/claude-code dep + SHA-256 hashing + hardcoded creds in +// THEIR api/auth-service.ts. They are not public CVE IDs and have nothing to do with the +// project being rendered, so every clean repo is told it has 3 CVEs. (2) `cvesFixed` +// counts .json files in .claude/security-scans/, so running the very scan the warning +// tells you to run "fixes" a CVE by writing a file. The counter converges to CLEAN +// without anything being scanned, let alone fixed. Upstream: ruvnet/ruflo#2694. +// +// This overlay reports what the newest scan ACTUALLY found, and never invents a CVE: +// totalCves/cvesFixed are pinned to 0 so the "⚠ N CVEs" branch can never fire again; +// real state is carried in `status`, which ruflo's own renderer prints verbatim. +// no scan yet → PENDING → "🛡 scan pending" (honest unknown, not green) +// findings > 0 → "N ISSUES" → red "🛡 n issues" (real count from the scan) +// clean + fresh → CLEAN → "🛡 ✓" +// clean + stale >7d → STALE → "🛡 scan stale" +// Returns `upstream` untouched on any unexpected error — a wrong overlay would be worse +// than the bug, so the failure mode is "no worse than ruflo". +function rufloLocalSecurity(cwd, upstream){ + try { + var fs = require("fs"), path = require("path"); + var dir = path.join(cwd, ".claude", "security-scans"); + var newest = null; + try { + fs.readdirSync(dir).forEach(function(f){ + if (f.slice(-5) !== ".json") return; + try { + var j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); + // Prefer the scan's own timestamp; fall back to mtime so a hand-written or + // older-format scan file still orders correctly instead of sorting to epoch 0. + var t = Date.parse(j && j.timestamp); + if (!t) { try { t = fs.statSync(path.join(dir, f)).mtimeMs; } catch(e){ t = 0; } } + if (!newest || t > newest.t) newest = { t: t, j: j }; + } catch(e){} // unreadable/!JSON scan file: ignore, never let it break the render + }); + } catch(e){} // no directory => never scanned + if (!newest) return { status: "PENDING", cvesFixed: 0, totalCves: 0 }; + var s = newest.j.summary || {}; + var n = typeof s.total === "number" ? s.total + : (Array.isArray(newest.j.findings) ? newest.j.findings.length : 0); + if (n > 0) return { status: n + " ISSUE" + (n === 1 ? "" : "S"), cvesFixed: 0, totalCves: 0 }; + var staleMs = Number(process.env.RUFLO_SCAN_STALE_MS || 7 * 24 * 3600 * 1000); + if (staleMs > 0 && newest.t && (Date.now() - newest.t) > staleMs) { + return { status: "STALE", cvesFixed: 0, totalCves: 0 }; + } + return { status: "CLEAN", cvesFixed: 0, totalCves: 0 }; + } catch(e){ return upstream; } +} +// ── insight-row companion to rufloLocalSecurity ────────────────────────────── +// The fabricated count reaches the render through a SECOND, independent path: the +// CLI builds the line-3 insight itself (funnel/insights.js securityInsight → +// `pending = s.totalCves - s.cvesFixed`) and ships it as pre-rendered promo TEXT. +// Overlaying data.security cannot fix that — the sentence is already baked, so a +// repo with a clean scan still gets "⚠ 1 CVE pending". This rebuilds that one +// sentence from the real scan, or drops it when there is nothing to say. +// Matched on TEXT, not id: promo.js reduces the insight to {text, kind} and throws +// the id away, so `insight-cves-pending` is not observable by the time we see it. +// Only ever touches a CVE-worded insight — every other insight/tip/promo passes +// through untouched, so the funnel rotation is preserved. +function rufloHonestInsight(promo, sec){ + try { + if (!promo || promo.kind !== "insight" || typeof promo.text !== "string") return promo; + if (!/\bCVEs?\b/.test(promo.text)) return promo; // a different insight — not ours to touch + if (!sec) return null; + if (sec.status === "PENDING") return { text: "🛡 Security scan pending — Run ruflo security scan --depth full", kind: "insight" }; + if (sec.status === "STALE") return { text: "🛡 Security scan stale — Run ruflo security scan --depth full", kind: "insight" }; + var m = /^(\d+) ISSUE/.exec(sec.status || ""); + if (m) { + var n = Number(m[1]); + return { text: "⚠ " + n + " security issue" + (n === 1 ? "" : "s") + " found — see .claude/security-scans", kind: "insight" }; + } + return null; // CLEAN: say nothing. The slot falls blank rather than nagging about a lie. + } catch(e){ return promo; } +} /* ruflo-seg:END */ diff --git a/tests/kit/statusline.test.mjs b/tests/kit/statusline.test.mjs new file mode 100644 index 00000000..9db02abc --- /dev/null +++ b/tests/kit/statusline.test.mjs @@ -0,0 +1,106 @@ +// fixStatusline's security-overlay injection — the stopgap for ruflo's fabricated +// CVE counter (@claude-flow/cli funnel/local-signals.js getSecurityStatus: a hardcoded +// `totalCves = 3` naming ruflo's OWN v3 roadmap items, with cvesFixed derived from +// scans.length — a FILE count). Hermetic: a synthetic global-root fixture stands in for +// the installed CLI, so the upstream-defect gate can be driven both ways without npm, +// network, or a real ruflo install. +// +// The retirement test is the important one: the kit must STOP patching the moment +// upstream ships a fix, without anyone editing a pinned version number here. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; +import { fixStatusline, upstreamCveCounterFabricated } from '../../src/lib/statusline.mjs'; + +// Minimal stand-in for ruflo's real statusline: only the shapes fixStatusline keys off. +const HOST = `#!/usr/bin/env node +let ver = "3.0.0"; +function applyLocalOverlays(data) { return data; } +function getStatuslineData() { return { security: { status: 'IN_PROGRESS', cvesFixed: 2, totalCves: 3 } }; } +function generateStatusline() { return 'x'; } +console.log(generateStatusline()) +`; + +// The buggy shape fixStatusline probes for; `fixed` models an upstream repair. +const signalsSrc = (buggy) => (buggy + ? 'export function getSecurityStatus(cwd) {\n let cvesFixed = 0;\n const totalCves = 3;\n cvesFixed = Math.min(totalCves, scans.length);\n}\n' + : 'export function getSecurityStatus(cwd) {\n const findings = readScan(cwd);\n return { status: findings.length ? "ISSUES" : "CLEAN" };\n}\n'); + +function fixture({ buggyUpstream }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-sl-')); + const proj = path.join(dir, 'proj'); + fs.mkdirSync(path.join(proj, '.claude', 'helpers'), { recursive: true }); + fs.writeFileSync(path.join(proj, '.claude', 'helpers', 'statusline.cjs'), HOST); + + const groot = path.join(dir, 'groot'); + const funnel = path.join(groot, 'ruflo', 'node_modules', '@claude-flow', 'cli', 'dist', 'src', 'funnel'); + fs.mkdirSync(funnel, { recursive: true }); + fs.writeFileSync(path.join(funnel, 'local-signals.js'), signalsSrc(buggyUpstream)); + _setGlobalRootForTest(groot); + return { proj, sl: path.join(proj, '.claude', 'helpers', 'statusline.cjs') }; +} + +const count = (s, re) => (s.match(re) || []).length; + +test('gate detects the fabricated CVE counter in a buggy CLI', () => { + fixture({ buggyUpstream: true }); + assert.equal(upstreamCveCounterFabricated(), true); +}); + +test('gate goes quiet once upstream repairs getSecurityStatus', () => { + fixture({ buggyUpstream: false }); + assert.equal(upstreamCveCounterFabricated(), false); +}); + +test('overlay is injected while the upstream defect is present', () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + const r = fixStatusline(proj); + assert.equal(r.securityOverlay, true); + const out = fs.readFileSync(sl, 'utf8'); + assert.match(out, /ruflo-sec:BEGIN/); + assert.match(out, /function rufloLocalSecurity/); + assert.match(out, /d\.security = rufloLocalSecurity/); + assert.match(out, /d\.promo = rufloHonestInsight/); +}); + +test('injected statusline is syntactically valid', () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + fixStatusline(proj); + execFileSync(process.execPath, ['--check', sl], { stdio: 'ignore' }); // throws on bad syntax +}); + +test('injection is idempotent — repeated syncs never stack blocks', () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + fixStatusline(proj); fixStatusline(proj); + const r3 = fixStatusline(proj); + const out = fs.readFileSync(sl, 'utf8'); + assert.equal(count(out, /ruflo-sec:BEGIN/g), 1); + assert.equal(count(out, /ruflo-seg:BEGIN/g), 1); + assert.equal(r3.applied, false, 'a converged file must report no change'); +}); + +// The self-retirement contract: no version pin, no manual cleanup step. +test('overlay retires itself once upstream is fixed', () => { + const { proj, sl } = fixture({ buggyUpstream: true }); + fixStatusline(proj); + assert.match(fs.readFileSync(sl, 'utf8'), /ruflo-sec:BEGIN/); + + // Upstream ships the fix underneath us; the next sync must strip the stopgap. + const funnel = path.join(_globalRootOf(sl), 'ruflo', 'node_modules', '@claude-flow', 'cli', 'dist', 'src', 'funnel'); + fs.writeFileSync(path.join(funnel, 'local-signals.js'), signalsSrc(false)); + + const r = fixStatusline(proj); + assert.equal(r.securityOverlay, false); + const out = fs.readFileSync(sl, 'utf8'); + assert.equal(count(out, /ruflo-sec:BEGIN/g), 0, 'stopgap must be gone'); + assert.match(out, /ruflo-seg:BEGIN/, 'the activation footer must survive'); +}); + +// The fixture's groot sits next to the project dir: /proj/... and /groot. +function _globalRootOf(slPath) { + return path.join(slPath, '..', '..', '..', '..', 'groot'); +} diff --git a/tests/statusline-segments.test.cjs b/tests/statusline-segments.test.cjs index 534b58aa..8fcba2d9 100644 --- a/tests/statusline-segments.test.cjs +++ b/tests/statusline-segments.test.cjs @@ -38,6 +38,21 @@ try { process.exit(2); } +// The security overlay ships in the same block (it must: the strip regex in +// statusline.mjs is non-global, so a second ruflo-seg block would leak on re-injection). +let rufloLocalSecurity, rufloHonestInsight, rufloAidefenceState; +try { + // eslint-disable-next-line no-eval + rufloLocalSecurity = eval('(function(){' + block + '\nreturn rufloLocalSecurity;})()'); + // eslint-disable-next-line no-eval + rufloHonestInsight = eval('(function(){' + block + '\nreturn rufloHonestInsight;})()'); + // eslint-disable-next-line no-eval + rufloAidefenceState = eval('(function(){' + block + '\nreturn rufloAidefenceState;})()'); +} catch (e) { + console.error('FATAL: could not extract security overlay fns:', e.message); + process.exit(2); +} + // ── Test harness ──────────────────────────────────────────────────────────── let passed = 0, failed = 0; const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -61,9 +76,11 @@ function absent(hay, needle) { assert(!hay.includes(needle), `expected output NO console.log('statusline activation-footer renderer'); // ── empty / absent ──────────────────────────────────────────────────────── -// Note: aidefence is sourced from the *global* ruflo install, so it may render on -// a machine that has ruflo installed. Assert only that the project-data segments -// (SONA / route / proof) are gated off — those are what an empty fixture controls. +// Note: the aidefence segment is ALARM-ONLY and reads the *global* ruflo install, so on +// a machine whose ruflo is missing aidefence it legitimately renders here. Assert only +// that the project-data segments (SONA / route / proof) are gated off — those are what +// an empty fixture controls. The aidefence polarity is covered by its own suite below, +// against fixture trees rather than whatever this machine happens to have installed. test('empty project → no SONA/route/proof segments', () => { const out = strip(rufloActivationSegments(mkFixture({}))); absent(out, '🧠 SONA'); @@ -249,5 +266,216 @@ test('Δ LoRA field is never rendered (F4 gate honored)', () => { absent(out, 'Δ LoRA'); }); +// ── security overlay: ruflo's fabricated CVE counter ──────────────────────── +// Upstream getSecurityStatus() (@claude-flow/cli funnel/local-signals.js) hardcodes +// `const totalCves = 3` — ruflo's OWN v3 roadmap items, not the rendered project's risk — +// and derives cvesFixed from scans.length, a FILE count. So a pristine repo is told it +// has 3 CVEs, and running the suggested scan "fixes" one by writing a file. The overlay +// reports what the newest scan actually found and never invents a CVE. +console.log('\nsecurity overlay (fabricated-CVE fix)'); + +const scanFixture = (files) => mkFixture(Object.fromEntries( + Object.entries(files).map(([f, o]) => ['.claude/security-scans/' + f, o]))); +const iso = (ms) => new Date(Date.now() + ms).toISOString(); + +test('never scanned → PENDING (honest unknown, not a false green)', () => { + const r = rufloLocalSecurity(mkFixture({}), { status: 'UPSTREAM' }); + assert(r.status === 'PENDING', 'expected PENDING, got ' + r.status); +}); + +test('clean fresh scan → CLEAN', () => { + const r = rufloLocalSecurity(scanFixture({ + 'scan-all-full.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, + }), null); + assert(r.status === 'CLEAN', 'expected CLEAN, got ' + r.status); +}); + +test('real findings → "N ISSUES" with the true count', () => { + const r = rufloLocalSecurity(scanFixture({ + 'scan.json': { timestamp: iso(0), summary: { critical: 1, high: 2, total: 3 }, findings: [1, 2, 3] }, + }), null); + assert(r.status === '3 ISSUES', 'expected "3 ISSUES", got ' + r.status); +}); + +test('single finding is singular ("1 ISSUE")', () => { + const r = rufloLocalSecurity(scanFixture({ + 'scan.json': { timestamp: iso(0), summary: { total: 1 }, findings: [1] }, + }), null); + assert(r.status === '1 ISSUE', 'expected "1 ISSUE", got ' + r.status); +}); + +test('clean but stale scan → STALE (not a stale green tick)', () => { + const r = rufloLocalSecurity(scanFixture({ + 'scan.json': { timestamp: iso(-30 * 864e5), summary: { total: 0 }, findings: [] }, + }), null); + assert(r.status === 'STALE', 'expected STALE, got ' + r.status); +}); + +// THE regression this whole patch exists for. +test('N clean scan FILES never fabricate CVEs (the upstream file-count bug)', () => { + const r = rufloLocalSecurity(scanFixture({ + 'a.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, + 'b.json': { timestamp: iso(1), summary: { total: 0 }, findings: [] }, + 'c.json': { timestamp: iso(2), summary: { total: 0 }, findings: [] }, + }), null); + assert(r.status === 'CLEAN', 'three clean scans must be CLEAN, got ' + r.status); + assert(r.totalCves === 0 && r.cvesFixed === 0, 'file count must never become a CVE count'); +}); + +test('totalCves/cvesFixed are pinned to 0 in every state (⚠ N CVEs can never fire)', () => { + const states = [ + mkFixture({}), + scanFixture({ 's.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] } }), + scanFixture({ 's.json': { timestamp: iso(0), summary: { total: 9 }, findings: [1] } }), + scanFixture({ 's.json': { timestamp: iso(-30 * 864e5), summary: { total: 0 }, findings: [] } }), + ]; + for (const dir of states) { + const r = rufloLocalSecurity(dir, null); + assert(r.totalCves === 0 && r.cvesFixed === 0, + 'CVE counters must stay 0, got ' + JSON.stringify(r)); + } +}); + +test('newest scan wins over older ones', () => { + const r = rufloLocalSecurity(scanFixture({ + 'old.json': { timestamp: iso(-864e5), summary: { total: 7 }, findings: [1] }, + 'new.json': { timestamp: iso(0), summary: { total: 0 }, findings: [] }, + }), null); + assert(r.status === 'CLEAN', 'newest (clean) scan must win, got ' + r.status); +}); + +test('malformed scan JSON is ignored, never throws', () => { + const dir = mkFixture({ + '.claude/security-scans/broken.json': 'not json at all{{', + '.claude/security-scans/good.json': JSON.stringify({ timestamp: iso(0), summary: { total: 2 }, findings: [1, 2] }), + }); + const r = rufloLocalSecurity(dir, null); + assert(r.status === '2 ISSUES', 'expected "2 ISSUES" from the readable scan, got ' + r.status); +}); + +test('findings[] length is used when summary.total is absent', () => { + const r = rufloLocalSecurity(scanFixture({ + 'scan.json': { timestamp: iso(0), findings: [1, 2, 3, 4] }, + }), null); + assert(r.status === '4 ISSUES', 'expected "4 ISSUES", got ' + r.status); +}); + +// ── insight row: the CLI bakes the fabricated count into promo TEXT ────────── +// funnel/insights.js computes `pending = totalCves - cvesFixed` CLI-side and ships a +// finished sentence, so overlaying data.security alone still leaves "⚠ 1 CVE pending" +// on line 3. promo.js drops the insight id, so this must match on text. +const cveInsight = (n) => ({ text: `⚠ ${n} CVE${n === 1 ? '' : 's'} pending — Run ruflo security scan --depth full`, kind: 'insight' }); + +test('fabricated CVE insight is dropped when the real scan is CLEAN', () => { + const r = rufloHonestInsight(cveInsight(1), { status: 'CLEAN', cvesFixed: 0, totalCves: 0 }); + assert(r === null, 'clean scan must not nag about CVEs, got ' + JSON.stringify(r)); +}); + +test('CVE insight becomes an honest scan-pending prompt when never scanned', () => { + const r = rufloHonestInsight(cveInsight(3), { status: 'PENDING', cvesFixed: 0, totalCves: 0 }); + absent(r.text, 'CVE'); + contains(r.text, 'scan pending'); +}); + +test('CVE insight becomes a real issue count when the scan found things', () => { + const r = rufloHonestInsight(cveInsight(1), { status: '4 ISSUES', cvesFixed: 0, totalCves: 0 }); + contains(r.text, '4 security issues'); + absent(r.text, 'CVE'); +}); + +test('CVE insight reports a stale scan honestly', () => { + const r = rufloHonestInsight(cveInsight(2), { status: 'STALE', cvesFixed: 0, totalCves: 0 }); + contains(r.text, 'scan stale'); + absent(r.text, 'CVE'); +}); + +test('non-CVE insights pass through untouched (funnel rotation preserved)', () => { + const tip = { text: '💾 ruflo session restore --latest brings back your last session', kind: 'educational' }; + assert(rufloHonestInsight(tip, { status: 'CLEAN' }) === tip, 'educational tip must pass through by identity'); + const other = { text: '🧬 flywheel headline', kind: 'insight' }; + assert(rufloHonestInsight(other, { status: 'CLEAN' }) === other, 'non-CVE insight must pass through by identity'); +}); + +test('null/!text promo is safe', () => { + assert(rufloHonestInsight(null, { status: 'CLEAN' }) === null, 'null promo stays null'); + const weird = { kind: 'insight' }; + assert(rufloHonestInsight(weird, { status: 'CLEAN' }) === weird, 'promo without text passes through'); +}); + +// ── AI defense (AIMDS): ALARM-ONLY, three-state, fail-safe ────────────────── +// Inverted from a permanent green "🛡 aidefence on" per issue #8's no-static-green-badge +// rule, and because that 🛡 collided with ruflo's line-2 scan shield — a DIFFERENT concern +// (`security scan` audits source; `security defend`/AIMDS screens prompts). +// +// The three-state contract is the safety property, not a nicety: inverting a signal also +// inverts its failure mode. "off" must require positive evidence (a real ruflo install +// lacking aidefence); an unresolvable probe must stay "unknown"/silent, never fail loud +// and wrong. Fixture trees are used so these hold regardless of what this machine has. +console.log('\naidefence segment (alarm-only inversion)'); + +const rufloTree = ({ aidefence }) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-')); + const root = path.join(dir, 'ruflo'); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'ruflo', version: '3.32.0' })); + if (aidefence) { + const ad = path.join(root, 'node_modules', '@claude-flow', 'aidefence'); + fs.mkdirSync(ad, { recursive: true }); + fs.writeFileSync(path.join(ad, 'package.json'), JSON.stringify({ name: '@claude-flow/aidefence' })); + } + return root; +}; + +test('aidefence installed → "on" (segment stays silent)', () => { + assert(rufloAidefenceState(rufloTree({ aidefence: true })) === 'on'); +}); + +test('ruflo present without aidefence → "off" (the alarm state)', () => { + assert(rufloAidefenceState(rufloTree({ aidefence: false })) === 'off'); +}); + +// The fail-safe: no ruflo located → we know nothing → must NOT claim the defense is off. +test('ruflo not locatable → "unknown", never "off"', () => { + assert(rufloAidefenceState('') === 'unknown', 'empty root must be unknown'); + assert(rufloAidefenceState(path.join(os.tmpdir(), 'definitely-not-here-' + Date.now())) === 'unknown', + 'nonexistent root must be unknown'); +}); + +test('a directory without ruflo/package.json is "unknown", not "off"', () => { + const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-')); // exists, but is not a ruflo install + assert(rufloAidefenceState(bare) === 'unknown'); +}); + +// End-to-end through the REAL segment. rufloFindRufloRoot reads process.execPath, which +// no fixture can control from in-process, so the probe is overridden (function +// declarations hoist, so the binding is reassignable) and the actual rendered string is +// asserted — rather than pattern-matching the template source, which would pass even if +// the segment never wired the probe up. +function renderWithRufloRoot(root) { + // eslint-disable-next-line no-eval + const seg = eval('(function(){' + block + + '\nrufloFindRufloRoot = function(){ return ' + JSON.stringify(root) + '; };' + + '\nreturn rufloActivationSegments;})()'); + return strip(seg(mkFixture({}))); +} + +test('aidefence present → segment renders NOTHING (issue #8: no static green badge)', () => { + const out = renderWithRufloRoot(rufloTree({ aidefence: true })); + absent(out, 'aidefence'); + absent(out, '🛡'); // must never collide with ruflo's line-2 scan shield +}); + +test('aidefence missing → the alarm renders, with no 🛡 and a named fix', () => { + const out = renderWithRufloRoot(rufloTree({ aidefence: false })); + contains(out, '⚠ aidefence OFF'); + contains(out, 'ak sync'); + absent(out, '🛡'); +}); + +test('unresolvable ruflo → silent (a probe miss must never fail loud and wrong)', () => { + const out = renderWithRufloRoot(''); + absent(out, 'aidefence'); +}); + console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); process.exit(failed === 0 ? 0 : 1);