mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
a63cdf0526
ADR-150 sibling-package integration. `@metaharness/redblue` runs an
adversarial red-team → judge → blue-team patch → retest → report loop
against an LLM target you own. Covers OWASP LLM Top-10 / NIST AI RMF
families (prompt injection, tool misuse, data leakage, jailbreaks,
denial-of-wallet). Joins score/genome/mcp-scan/threat-model/oia-audit
as the run-time red/blue layer of the security plugin surface.
Why integrate here vs ruflo-aidefence or ruflo-security-audit:
- aidefence is detection (PII / prompt-injection scan); redblue is
*generation* — they complement, redblue doesn't replace it
- security-audit is dep CVEs / static scan; redblue is run-time LLM eval
- ruflo-metaharness already carries the @metaharness/* wrapper pattern
(`_darwin.mjs`, `_harness.mjs`), the locator, the MCP-tool registration
surface, and the smoke/CI gates — redblue slots in as one more sibling
Surface added:
- `npx ruflo metaharness redblue {init|run|patch|attack|report}` CLI
- `metaharness_redblue` MCP tool (subcommand + family + tests + patch +
mockJudge + alertOnFail + timeoutMs)
- Skill discoverability via CLAUDE.md (both CLI catalog + MCP tool catalog)
- 11 metaharness subcommands (was 10), 13 MCP tools (was 12)
Safety posture (inherited verbatim from upstream; wrapper does NOT relax):
- upstream `src/config/safety.ts` hard-enforces no real credentials, no
live external targets (loopback / *.test / *.internal only), no shell,
no arbitrary network, no eval at config-load time — a config that
enables any of these errors at load
- `--mock-judge` is the $0 marker-fixture path for CI / offline; the real
model judge requires `OPENROUTER_API_KEY` and is capped by
`max_cost_usd` / `max_runtime_minutes` / `max_tests`
- wrapper shells out with `shell: false` argv arrays; never string interp
- ADR-150 graceful-degradation rule #3 — on install/network failure the
wrapper returns `{degraded: true, reason: ...}` and exits 0 so ruflo
stays operational
Upstream bug workaround (track for revert):
`@metaharness/redblue@0.1.1`'s CLI bootstrap uses
`isMain = import.meta.url === \`file://\${process.argv[1]}\``. When npx
links `redblue` → `dist/cli/index.js`, argv[1] is the symlink path but
import.meta.url is the resolved real path, so isMain is false and the
binary silently exits 0 with no output, no file, no error. Darwin's CLI
calls `main()` unconditionally and is unaffected. Workaround: install the
package once into `~/.ruflo/redblue-cache` via `npm install --prefix` and
invoke `node <abs_path>/dist/cli/index.js` directly — argv[1] then equals
the real path and isMain becomes true. First install hits npm's cache so
the cold path is ~200ms; warm invocations are ~20ms.
Locator hardening (prevents recurrence of artifact-mirror staleness):
The publish artifact at `v3/@claude-flow/cli/plugins/ruflo-metaharness/`
is regenerated only at `prepublishOnly`. On a dev tree with a fresh
script in source but a stale mirror, the locator would pick the mirror
because `_harness.mjs` exists in both. Locators in `commands/metaharness.ts`
and `mcp-tools/metaharness-tools.ts` now accept an optional `requiredScript`
that narrows the match to dirs containing the dispatched script. The
artifact mirror is also synced with the two new redblue scripts so the
publish artifact stays usable until the next `prepublishOnly`.
Gates updated to lock new state:
- smoke.sh: 9 subcommands in dispatcher (was 8), 11 in description list
(was 10), 13 MCP tools (was 12), 13 mcp-script-count (was 12), 14
MCP_SUCCESS_SEMANTIC footnotes (was 13), 13 `success: r.success`
occurrences (was 12), tilde-pin check covers @metaharness/redblue
- test-mcp-tools.mjs: `tools.length === 13`, `metaharness_redblue`
enumerated, attack-preview Phase-3 input, 180s timeout (cold-cache npx
fetch class)
- CLAUDE.md: surfaced under both CLI catalog and MCP-tool catalog (gated
by smoke 17z56 + 17z57)
Verification:
- `bash plugins/ruflo-metaharness/scripts/smoke.sh` → 120 passed, 0 failed
- `node plugins/ruflo-metaharness/scripts/test-mcp-tools.mjs` → 153 passed, 0 failed
- `node v3/@claude-flow/cli/bin/cli.js metaharness redblue init --out <path>`
→ writes the safety-asserting sample config, exits 0
- attack preview, mock-judge run (3-test → real findings report), and
CLI dispatcher all exercised end-to-end before commit
Co-Authored-By: RuFlo <ruv@ruv.net>
287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// redblue.mjs — wrapper around `@metaharness/redblue` for LLM red/blue testing.
|
|
//
|
|
// ADR-150 sibling-package integration: `@metaharness/redblue` runs an
|
|
// adversarial red-team → judge → blue-team patch loop against an LLM
|
|
// target you own. Upstream README: packages/redblue#readme on
|
|
// github.com/ruvnet/agent-harness-generator.
|
|
//
|
|
// SAFETY (enforced by upstream `src/config/safety.ts`, NOT this wrapper):
|
|
// - no real credentials (`allow_real_credentials:true` = load-time error)
|
|
// - no live external targets (validateTarget() rejects non-loopback)
|
|
// - no shell, no arbitrary network, no eval
|
|
// - sensitive outputs redacted before storage
|
|
// - `max_cost_usd` / `max_runtime_minutes` / `max_tests` cap every run
|
|
//
|
|
// USAGE
|
|
// node scripts/redblue.mjs init # write redblue.yaml
|
|
// node scripts/redblue.mjs run --mock-judge --tests 10 # $0 marker-fixture path
|
|
// node scripts/redblue.mjs run --tests 50 --patch # real judge (needs OPENROUTER_API_KEY)
|
|
// node scripts/redblue.mjs attack prompt --count 5 # preview attacks
|
|
// node scripts/redblue.mjs patch --mock-judge # baseline → patch → retest delta
|
|
// node scripts/redblue.mjs report --in report.json # render summary
|
|
//
|
|
// EXIT CODES
|
|
// 0 ran OK (or degraded — redblue not available)
|
|
// 1 --alert-on-fail and overall verdict was FAIL (after patch retest)
|
|
// 2 config / argv error
|
|
//
|
|
// REPORT FORMAT
|
|
// `run` / `patch` write a JSON report to --out (default: ./redblue-report.json).
|
|
// We re-emit it on stdout when --format json so MCP tools can parse it
|
|
// without round-tripping through disk. Markdown rendering is delegated
|
|
// to `redblue report` upstream.
|
|
|
|
import { runRedblue, emitRedblueDegradedJsonAndExit } from './_redblue.mjs';
|
|
import { existsSync, readFileSync, mkdtempSync, unlinkSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
const VALID_SUBCOMMANDS = new Set(['init', 'run', 'patch', 'attack', 'report']);
|
|
const VALID_ATTACK_FAMILIES = new Set(['prompt', 'tools', 'data', 'all']);
|
|
|
|
const ARGS = (() => {
|
|
const a = {
|
|
sub: null,
|
|
// shared
|
|
config: null,
|
|
out: null,
|
|
inPath: null,
|
|
format: 'json',
|
|
// run/patch
|
|
tests: null,
|
|
patch: false,
|
|
mockJudge: false,
|
|
alertOnFail: false,
|
|
// attack
|
|
family: null,
|
|
count: null,
|
|
// budget overrides (forwarded only when set)
|
|
maxCostUsd: null,
|
|
maxRuntimeMinutes: null,
|
|
timeoutMs: null,
|
|
};
|
|
const argv = process.argv.slice(2);
|
|
if (argv.length === 0) return a;
|
|
a.sub = argv[0];
|
|
for (let i = 1; i < argv.length; i++) {
|
|
const v = argv[i];
|
|
if (v === '--config') a.config = argv[++i];
|
|
else if (v === '--out') a.out = argv[++i];
|
|
else if (v === '--in') a.inPath = argv[++i];
|
|
else if (v === '--format') a.format = argv[++i];
|
|
else if (v === '--tests') a.tests = parseInt(argv[++i], 10);
|
|
else if (v === '--patch') a.patch = true;
|
|
else if (v === '--mock-judge') a.mockJudge = true;
|
|
else if (v === '--alert-on-fail') a.alertOnFail = true;
|
|
else if (v === '--count') a.count = parseInt(argv[++i], 10);
|
|
else if (v === '--max-cost-usd') a.maxCostUsd = parseFloat(argv[++i]);
|
|
else if (v === '--max-runtime-minutes') a.maxRuntimeMinutes = parseFloat(argv[++i]);
|
|
else if (v === '--timeout-ms') a.timeoutMs = parseInt(argv[++i], 10);
|
|
else if (v === '--help' || v === '-h') a.sub = 'help';
|
|
else if (!v.startsWith('-') && a.sub === 'attack' && !a.family) a.family = v;
|
|
// any other unknown flag — pass through to upstream redblue
|
|
else if (v.startsWith('--')) {
|
|
// forward unknown flags verbatim so we don't bottleneck new redblue features
|
|
a._passthrough = a._passthrough || [];
|
|
a._passthrough.push(v);
|
|
// peek for next-token value (only consume if it's not another flag)
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith('-')) {
|
|
a._passthrough.push(next);
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
return a;
|
|
})();
|
|
|
|
function printHelp() {
|
|
console.log('npx ruflo metaharness redblue <subcommand> [options]');
|
|
console.log('');
|
|
console.log('Subcommands:');
|
|
console.log(' init Write a sample redblue.yaml config');
|
|
console.log(' run Run red-team → judge → (optional --patch → retest) → report');
|
|
console.log(' patch Baseline → blue-team patch → retest delta');
|
|
console.log(' attack <prompt|tools|data|all> Preview generated attack test cases (no target call)');
|
|
console.log(' report Render an existing report.json as markdown');
|
|
console.log('');
|
|
console.log('Common options:');
|
|
console.log(' --config <path> Config YAML (default: redblue.yaml in cwd)');
|
|
console.log(' --out <path> Output path for run/patch (default: ./redblue-report.json)');
|
|
console.log(' --in <path> Input report path (for `report` subcommand)');
|
|
console.log(' --tests N How many test cases (run/patch)');
|
|
console.log(' --patch After baseline run, apply blue-team patches and retest');
|
|
console.log(' --mock-judge $0 TEST-ONLY marker fixture — for CI / offline');
|
|
console.log(' --alert-on-fail Exit 1 when overall post-patch verdict is FAIL');
|
|
console.log(' --format json|markdown Output format (default: json)');
|
|
console.log(' --max-cost-usd N Override config max_cost_usd');
|
|
console.log(' --max-runtime-minutes N Override config max_runtime_minutes');
|
|
console.log(' --timeout-ms N Subprocess hard timeout (default: 120000)');
|
|
console.log('');
|
|
console.log('Examples:');
|
|
console.log(' node scripts/redblue.mjs init');
|
|
console.log(' node scripts/redblue.mjs run --mock-judge --tests 10');
|
|
console.log(' node scripts/redblue.mjs run --tests 50 --patch # needs OPENROUTER_API_KEY');
|
|
console.log(' node scripts/redblue.mjs attack prompt --count 3');
|
|
console.log('');
|
|
console.log('SAFETY: upstream redblue enforces no-creds, no-live-targets, no-shell,');
|
|
console.log('no-eval at load time (src/config/safety.ts). This wrapper does not relax');
|
|
console.log('those — it only forwards argv with shell:false.');
|
|
}
|
|
|
|
function buildUpstreamArgs() {
|
|
switch (ARGS.sub) {
|
|
case 'init': {
|
|
const out = ARGS.out ?? null;
|
|
return ['init', ...(out ? ['--out', out] : [])];
|
|
}
|
|
case 'run': {
|
|
const a = ['run'];
|
|
if (ARGS.config) a.push('--config', ARGS.config);
|
|
if (ARGS.tests !== null && Number.isFinite(ARGS.tests)) a.push('--tests', String(ARGS.tests));
|
|
if (ARGS.patch) a.push('--patch');
|
|
if (ARGS.mockJudge) a.push('--mock-judge');
|
|
if (ARGS.out) a.push('--out', ARGS.out);
|
|
return a;
|
|
}
|
|
case 'patch': {
|
|
const a = ['patch'];
|
|
if (ARGS.config) a.push('--config', ARGS.config);
|
|
if (ARGS.mockJudge) a.push('--mock-judge');
|
|
if (ARGS.out) a.push('--out', ARGS.out);
|
|
return a;
|
|
}
|
|
case 'attack': {
|
|
if (!ARGS.family || !VALID_ATTACK_FAMILIES.has(ARGS.family)) {
|
|
console.error(`redblue: --attack family must be one of ${[...VALID_ATTACK_FAMILIES].join('|')} (got: ${ARGS.family ?? 'none'})`);
|
|
process.exit(2);
|
|
}
|
|
const a = ['attack', ARGS.family];
|
|
if (ARGS.count !== null && Number.isFinite(ARGS.count)) a.push('--count', String(ARGS.count));
|
|
return a;
|
|
}
|
|
case 'report': {
|
|
if (!ARGS.inPath) {
|
|
console.error('redblue: report requires --in <path>');
|
|
process.exit(2);
|
|
}
|
|
return ['report', '--in', ARGS.inPath];
|
|
}
|
|
default:
|
|
console.error(`redblue: unknown subcommand "${ARGS.sub}". Valid: ${[...VALID_SUBCOMMANDS].join(', ')}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
if (!ARGS.sub || ARGS.sub === 'help' || ARGS.sub === '--help' || ARGS.sub === '-h') {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
if (!VALID_SUBCOMMANDS.has(ARGS.sub)) {
|
|
console.error(`redblue: unknown subcommand "${ARGS.sub}". Valid: ${[...VALID_SUBCOMMANDS].join(', ')}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// For `run` and `patch` we want the report JSON in our hands even when
|
|
// the caller didn't pass --out (so MCP tools can read it back from stdout).
|
|
let synthOut = null;
|
|
if ((ARGS.sub === 'run' || ARGS.sub === 'patch') && !ARGS.out) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ruflo-redblue-'));
|
|
synthOut = join(dir, 'report.json');
|
|
ARGS.out = synthOut;
|
|
}
|
|
|
|
const upstreamArgs = buildUpstreamArgs();
|
|
if (ARGS._passthrough?.length) upstreamArgs.push(...ARGS._passthrough);
|
|
|
|
const r = runRedblue(upstreamArgs, { timeoutMs: ARGS.timeoutMs ?? undefined });
|
|
|
|
if (r.degraded) {
|
|
if (synthOut && existsSync(synthOut)) {
|
|
try { unlinkSync(synthOut); } catch { /* ignore */ }
|
|
}
|
|
emitRedblueDegradedJsonAndExit(r.reason);
|
|
return;
|
|
}
|
|
|
|
// Pass through stderr (progress) when format=markdown so users see it live
|
|
if (ARGS.format === 'markdown' && r.stderr) {
|
|
process.stderr.write(r.stderr);
|
|
}
|
|
|
|
// For run/patch: read the report we asked redblue to write.
|
|
let payload = null;
|
|
if (ARGS.sub === 'run' || ARGS.sub === 'patch') {
|
|
const reportPath = resolve(ARGS.out);
|
|
if (existsSync(reportPath)) {
|
|
try {
|
|
payload = JSON.parse(readFileSync(reportPath, 'utf-8'));
|
|
} catch (e) {
|
|
console.error(`redblue: failed to parse report at ${reportPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
if (synthOut) { try { unlinkSync(synthOut); } catch { /* ignore */ } }
|
|
process.exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decorate every payload with our own metadata + the safety boundary
|
|
// reminder so consumers always see "this was capability-contained".
|
|
const enriched = payload && typeof payload === 'object' ? {
|
|
...payload,
|
|
_ruflo: {
|
|
subcommand: ARGS.sub,
|
|
mockJudge: ARGS.mockJudge,
|
|
durationMs: r.durationMs,
|
|
safetyBoundary: 'enforced-by-upstream-redblue (no creds, no live targets, no shell, no eval)',
|
|
},
|
|
} : payload;
|
|
|
|
// Determine overall verdict for --alert-on-fail (post-patch run only).
|
|
// redblue reports include a `verdict` or `gate` field; we treat absence
|
|
// as "no data → no alert" rather than erroring.
|
|
let alertTriggered = false;
|
|
if (ARGS.alertOnFail && enriched && typeof enriched === 'object') {
|
|
const v = (enriched.verdict || enriched.gate || enriched.status || '').toString().toUpperCase();
|
|
if (v.includes('FAIL')) alertTriggered = true;
|
|
}
|
|
|
|
if (ARGS.format === 'json') {
|
|
if (enriched !== null) {
|
|
console.log(JSON.stringify(enriched, null, 2));
|
|
} else {
|
|
// init / attack / report subcommands — forward upstream stdout verbatim
|
|
// (it's already markdown/yaml). Wrap in a tiny JSON envelope.
|
|
console.log(JSON.stringify({
|
|
subcommand: ARGS.sub,
|
|
ok: r.exitCode === 0,
|
|
durationMs: r.durationMs,
|
|
stdout: r.stdout,
|
|
_ruflo: { safetyBoundary: 'enforced-by-upstream-redblue' },
|
|
}, null, 2));
|
|
}
|
|
} else {
|
|
// markdown / passthrough
|
|
if (r.stdout) process.stdout.write(r.stdout);
|
|
if (enriched && typeof enriched === 'object') {
|
|
console.log('');
|
|
console.log('---');
|
|
console.log('## redblue report (parsed)');
|
|
console.log('```json');
|
|
console.log(JSON.stringify(enriched, null, 2));
|
|
console.log('```');
|
|
}
|
|
}
|
|
|
|
// Clean up synthetic temp file
|
|
if (synthOut && existsSync(synthOut)) {
|
|
try { unlinkSync(synthOut); } catch { /* ignore */ }
|
|
}
|
|
|
|
if (alertTriggered) process.exit(1);
|
|
if (r.exitCode !== 0) process.exit(r.exitCode);
|
|
}
|
|
|
|
main();
|