Skip to content

Commit 3fb26e8

Browse files
authored
fix(statusline): overlay ruflo's fabricated CVE counter with the real scan (#26)
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.
1 parent 6105d9f commit 3fb26e8

5 files changed

Lines changed: 582 additions & 19 deletions

File tree

‎src/commands/status.mjs‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { scanRvf } from '../lib/rvf.mjs';
1212
import { registry, syncBlocks } from '../lib/blocks.mjs';
1313
import { loadKitConfig } from '../lib/config.mjs';
1414
import { driftReport, selfDrift } from '../lib/versions.mjs';
15+
import { upstreamCveCounterFabricated, fixStatusline } from '../lib/statusline.mjs';
1516
import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs';
1617
import { readJson } from '../lib/settings.mjs';
1718
import { have } from '../lib/exec.mjs';
@@ -261,10 +262,35 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
261262
// statusline footer (project scope)
262263
const sl = paths.projectStatusline(cwd);
263264
if (fs.existsSync(sl)) {
264-
const hasFooter = fs.readFileSync(sl, 'utf8').includes('ruflo-seg:BEGIN');
265-
rows.push(row('statusline', hasFooter ? 'ok' : 'warn',
266-
hasFooter ? 'activation footer present' : 'statusline present but footer missing',
267-
hasFooter ? null : 'sync re-injects the footer'));
265+
const slSrc = fs.readFileSync(sl, 'utf8');
266+
const hasFooter = slSrc.includes('ruflo-seg:BEGIN');
267+
// Drift is "would a sync CHANGE this file?", which fixStatusline's dry run answers
268+
// exactly. A marker-presence test alone cannot see CONTENT drift: after a kit upgrade
269+
// revises the footer or the security overlay, the marker is still there, this row
270+
// reports 'ok', and — because sync builds its plan from rows carrying a `fix` — the
271+
// re-injection never runs and the stale block survives indefinitely. Observed live:
272+
// an updated overlay silently failed to land for exactly this reason.
273+
let wouldChange = !hasFooter;
274+
try { wouldChange = fixStatusline(cwd, { dryRun: true }).applied; } catch { /* keep marker fallback */ }
275+
rows.push(row('statusline', wouldChange ? 'warn' : 'ok',
276+
wouldChange
277+
? (hasFooter ? 'injected blocks are out of date' : 'statusline present but footer missing')
278+
: 'activation footer present and current',
279+
wouldChange ? 'sync re-injects the footer' : null));
280+
// The CVE-counter overlay is tracked SEPARATELY from the footer: a footer-only
281+
// check reports 'ok' while the statusline still renders ruflo's fabricated
282+
// "⚠ 3 CVEs" (hardcoded totalCves, cvesFixed from a file count). Only warn while
283+
// the upstream defect is actually present — once ruflo fixes getSecurityStatus
284+
// the overlay is intentionally absent, and this row must go quiet on its own
285+
// rather than nag for a patch that is no longer wanted.
286+
if (upstreamCveCounterFabricated()) {
287+
const patched = slSrc.includes('ruflo-sec:BEGIN');
288+
rows.push(row('statusline/cve', patched ? 'ok' : 'warn',
289+
patched
290+
? 'CVE counter overlaid with real scan results'
291+
: 'statusline shows ruflo\'s fabricated CVE count (upstream defect)',
292+
patched ? null : 'sync injects the security overlay'));
293+
}
268294
} else {
269295
rows.push(row('statusline', 'info', 'no project statusline here (created by setup)'));
270296
}

‎src/lib/statusline.mjs‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import fs from 'node:fs';
99
import path from 'node:path';
1010
import { fileURLToPath } from 'node:url';
1111
import { execFileSync } from 'node:child_process';
12-
import { projectStatusline, projectSettings } from './paths.mjs';
12+
import { projectStatusline, projectSettings, rufloCliDist } from './paths.mjs';
1313
import { installedVersion } from './versions.mjs';
1414
import { readJson, writeJsonWithBackup } from './settings.mjs';
1515

@@ -18,6 +18,61 @@ const FOOTER_TEMPLATE = path.join(
1818

1919
const eol = (s) => (s.includes('\r\n') ? '\r\n' : '\n');
2020

21+
// Security overlay wrapper. Wraps getStatuslineData() rather than patching
22+
// applyLocalOverlays(), because applyLocalOverlays is NOT on every path: the
23+
// fresh-cache early return (`if (cache.fresh && cache.promoFresh) return
24+
// overlayMemoPromo(cache.data)`) bypasses it, so for the 60s TTL a patched
25+
// applyLocalOverlays is simply never called and the fabricated count renders
26+
// anyway (verified empirically — the overlay had no effect until this wrapper).
27+
// Wrapping the single entry point covers all four return paths (CLI delegation,
28+
// fresh cache, stale-while-revalidate, local fallback) with one injection.
29+
//
30+
// Relies on function-declaration hoisting: `function getStatuslineData()` is
31+
// initialized before any top-level code runs, so this block — injected near the
32+
// top of the file — can reassign the binding, and the later declaration does not
33+
// re-execute and clobber it. The typeof guard keeps it inert on any template that
34+
// lacks the function (e.g. the minimal statusline-v3.cjs).
35+
const SEC_WRAP = [
36+
'/* ruflo-sec:BEGIN */',
37+
'try {',
38+
' if (typeof getStatuslineData === "function") {',
39+
' var _rufloOrigGetStatuslineData = getStatuslineData;',
40+
' getStatuslineData = function(){',
41+
' var d = _rufloOrigGetStatuslineData.apply(this, arguments);',
42+
' try {',
43+
' if (d) {',
44+
' d.security = rufloLocalSecurity(process.cwd(), d.security);',
45+
' d.promo = rufloHonestInsight(d.promo, d.security);',
46+
' }',
47+
' } catch(e){}',
48+
' return d;',
49+
' };',
50+
' }',
51+
'} catch(e){}',
52+
'/* ruflo-sec:END */',
53+
].join('\n');
54+
const SEC_WRAP_STRIP = /\/\* ruflo-sec:BEGIN \*\/[\s\S]*?\/\* ruflo-sec:END \*\/\n?/g;
55+
56+
/** Upstream defect: ruvnet/ruflo#2694.
57+
* True while ruflo's getSecurityStatus() still FABRICATES the CVE count — i.e. the
58+
* installed CLI still has `const totalCves = 3` (a hardcoded constant naming ruflo's
59+
* own v3 roadmap items, not the rendered project's risk) with cvesFixed derived from
60+
* scans.length (a FILE count, not findings). Read-only probe of the installed CLI.
61+
*
62+
* This is the stopgap's self-retirement gate, mirroring improvement-eval's --cli-check
63+
* (#2222): detect the defect in shipped code rather than pinning a version number, so
64+
* the kit stops patching the moment upstream fixes it — no release-tracking required.
65+
* Unreadable/absent/changed => false (fail safe: never patch what we cannot verify is
66+
* broken; the worst case is ruflo's own unmodified behavior). */
67+
export function upstreamCveCounterFabricated() {
68+
try {
69+
const f = path.join(rufloCliDist(), 'funnel', 'local-signals.js');
70+
if (!fs.existsSync(f)) return false;
71+
const src = fs.readFileSync(f, 'utf8');
72+
return /const totalCves = 3\b/.test(src) && /scans\.length/.test(src);
73+
} catch { return false; }
74+
}
75+
2176
export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) {
2277
const file = projectStatusline(root);
2378
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 } = {}) {
3590
const footer = fs.readFileSync(FOOTER_TEMPLATE, 'utf8').replace(/\r\n/g, '\n').trim();
3691
s = s.replace(/\/\* ruflo-seg:BEGIN \*\/[\s\S]*?\/\* ruflo-seg:END \*\/\n?/, '');
3792
s = s.replace(/ \+ rufloActivationSegments\(process\.cwd\(\)\)/g, '');
93+
// (d) security overlay: stripped unconditionally BEFORE the gate is consulted, so the
94+
// stopgap retires itself on the first sync after upstream fixes getSecurityStatus.
95+
s = s.replace(SEC_WRAP_STRIP, '');
96+
const securityOverlay = upstreamCveCounterFabricated();
3897
const lines = s.split('\n');
3998
const at = lines[0]?.startsWith('#!') ? 1 : 0;
40-
lines.splice(at, 0, footer);
99+
lines.splice(at, 0, securityOverlay ? footer + '\n' + SEC_WRAP : footer);
41100
s = lines.join('\n');
42101
s = s.replace(/console\.log\(generateStatusline\(\)\)/, 'console.log(generateStatusline() + rufloActivationSegments(process.cwd()))');
43102

@@ -71,5 +130,5 @@ export function fixStatusline(root = process.cwd(), { dryRun = false } = {}) {
71130
repointed = true;
72131
}
73132

74-
return { file, applied: out !== raw, repointed, version: ver };
133+
return { file, applied: out !== raw, repointed, version: ver, securityOverlay };
75134
}

0 commit comments

Comments
 (0)