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);