mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-09 22:13:39 +09:00
* fix(memory): #1883 + #1884 — WSL path resolution + import-key sanitization #1883 — memory_import_claude(allProjects=false) returned imported=0 on WSL because project-hash derivation only POSIX-slash-replaced cwd. A WSL bridge running Ruflo with cwd `/mnt/c/Users/x/Project Name` could never match the Windows-host Claude Code project dir `C--Users-x-Project-Name`. Fix: resolveProjectMemoryDir() tries multiple candidate hashes (legacy POSIX, WSL `/mnt/<drive>/`-translated, leading-dash-stripped, space-replaced) and returns the first that exists. Also adds optional `projectPath` input parameter as an explicit override for callers in cross-environment setups. #1884 — memory_import_claude wrote keys built from raw frontmatter `name` and section titles directly to storeEntry, bypassing validateMemoryInput. memory_delete enforces it. Any markdown file with shell-metacharacters in section headings (`(`, `)`, `{`, `}`, `[`, `]`, `<`, `>`, `!`, `#`, etc.) produced keys that imported successfully but could not be deleted through MCP — stranded in the database, requiring direct SQLite intervention. Fix: sanitizeMemoryKey() replaces every char in DANGEROUS_KEY_CHARS with `_` before storeEntry. The set is shared with DANGEROUS_KEY_PATTERN used by validateMemoryInput so write-side and read-side can never drift apart. CI guard (catches the bug class going forward): - plugins/ruflo-core/scripts/test-memory-import.mjs: 12 regression checks covering static dist scan (helpers defined + called from import handler), legacy-code negative-attestation, sanitizer property test against 27 adversarial inputs, WSL-fixture and POSIX-compat candidate-set checks. - .github/workflows/v3-ci.yml: new `memory-import-smoke` job (ubuntu+macos), wired into witness-verify needs[]. - verification/witness-fixes.json: #1883 + #1884 markers (resolve…, sanitize…) — witness-verify cryptographically attests both helpers are present in dist on every PR. Why our verification missed these (and is now closed): - Witness manifest catches drift from a known-good baseline. Both bugs were always there; no drift to detect. Closed via new markers + the static dist scan that asserts call-sites, not just helper presence. - CI matrix has Linux/macOS/Windows runners but no WSL-bridge fixture. Closed via deriveCandidates() unit assertion on synthetic WSL cwd. - No round-trip test for write-path/read-path key invariant. Closed via sanitizer property test on 27 adversarial inputs. Local verification: 12/12 regression guards pass; witness-verify clean (99 fixes pass, signature valid). Closes #1883 Closes #1884 Co-Authored-By: RuFlo <ruv@ruv.net> * ci: memory-import-smoke uses recursive --no-bail build The targeted `--filter @claude-flow/cli... run build` doesn't pull in @claude-flow/swarm, which @claude-flow/cli imports from at TS-compile time (TS6305 — referenced .d.ts not built from source). Switching to the same `pnpm --recursive --no-bail run build || true` pattern that witness-verify uses. The smoke script already fails loudly if the cli dist didn't get produced, so we don't lose signal. Co-Authored-By: RuFlo <ruv@ruv.net> --------- Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
175 lines
8.2 KiB
JavaScript
Executable File
175 lines
8.2 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Regression guard for ruvnet/ruflo#1883 + #1884.
|
|
*
|
|
* #1883 — memory_import_claude `allProjects=false` failed under WSL because
|
|
* project-hash derivation only POSIX-slash-replaced cwd. We now try
|
|
* multiple candidate hashes (POSIX, WSL-translated `/mnt/<d>/` →
|
|
* `<D>-...`, leading-dash-stripped, space-replaced) and accept an
|
|
* explicit `projectPath` override.
|
|
*
|
|
* #1884 — keys produced by memory_import_claude included raw frontmatter
|
|
* names + section titles, which could contain shell-metacharacters
|
|
* in validateMemoryInput's dangerous-pattern set. memory_delete then
|
|
* rejected those keys, stranding them. We now sanitize at write-time
|
|
* via sanitizeMemoryKey.
|
|
*
|
|
* This script does three things:
|
|
* 1. Static dist scan — assert sanitizeMemoryKey + resolveProjectMemoryDir
|
|
* are present in dist and called from the import handler. Catches
|
|
* regressions where the helpers exist but stop being called.
|
|
* 2. Property check — for every char in DANGEROUS_KEY_CHARS, sanitize and
|
|
* assert the result passes validateMemoryInput's regex. Proves the
|
|
* write-path → delete-path round-trip can never strand a key again.
|
|
* 3. WSL-fixture check — feed a synthetic `/mnt/c/Users/x/Project Name`
|
|
* through resolveProjectMemoryDir's candidate generator and assert the
|
|
* Claude-Code Windows hash form is among the candidates.
|
|
*
|
|
* Combined with witness markers (#1883-cli, #1884-cli) — which attest that
|
|
* the marker substrings are present in dist — this is the three-way coverage
|
|
* (#1874 pattern: dist scan + behavioral + cryptographic) that gates these
|
|
* regressions.
|
|
*/
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const REPO_ROOT = resolve(process.argv[2] ?? process.cwd());
|
|
const DIST = resolve(REPO_ROOT, 'v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js');
|
|
|
|
let failed = 0;
|
|
const fail = (m) => { console.error(`FAIL: ${m}`); failed++; };
|
|
const pass = (m) => console.log(`ok: ${m}`);
|
|
|
|
if (!existsSync(DIST)) {
|
|
fail(`${DIST} not found — run \`npm --prefix v3/@claude-flow/cli run build\` first`);
|
|
process.exit(1);
|
|
}
|
|
const distSrc = readFileSync(DIST, 'utf-8');
|
|
|
|
// ---------- 1. Static dist scan ----------
|
|
|
|
const requiredMarkers = [
|
|
// #1884 — sanitizer helper present and called from the two storeEntry sites
|
|
{ id: '#1884-helper-defined', re: /function sanitizeMemoryKey\s*\(/, hint: 'sanitizeMemoryKey() must be defined in dist' },
|
|
{ id: '#1884-call-site-1', re: /sanitizeMemoryKey\(`claude:\$\{memFile\.project\}:\$\{name\}`\)/, hint: 'first storeEntry import-key must go through sanitizeMemoryKey' },
|
|
{ id: '#1884-call-site-2', re: /sanitizeMemoryKey\(`claude:\$\{memFile\.project\}:\$\{name\}:\$\{sectionTitle\.slice\(0,\s*50\)\}`\)/, hint: 'second (sectioned) storeEntry import-key must go through sanitizeMemoryKey' },
|
|
|
|
// #1883 — multi-candidate resolver present, WSL branch present, override plumbed
|
|
{ id: '#1883-resolver-defined', re: /function resolveProjectMemoryDir\s*\(/, hint: 'resolveProjectMemoryDir() must be defined in dist' },
|
|
{ id: '#1883-wsl-branch', re: /\/\^\\\/mnt\\\/\(\[a-z\]\)\(\\\/\.\*\)\?\$\/i/, hint: 'WSL /mnt/<drive>/ regex must be present in dist' },
|
|
{ id: '#1883-override-input', re: /projectPathOverride\s*=\s*input\.projectPath/, hint: 'memory_import_claude must read projectPath from input' },
|
|
{ id: '#1883-resolver-call', re: /resolveProjectMemoryDir\(claudeProjectsDir,\s*projectPathOverride\)/, hint: 'import handler must call resolveProjectMemoryDir' },
|
|
];
|
|
|
|
for (const marker of requiredMarkers) {
|
|
if (marker.re.test(distSrc)) pass(`${marker.id} — dist contains required code`);
|
|
else fail(`${marker.id} — ${marker.hint}`);
|
|
}
|
|
|
|
// Negative-attestation: the legacy single-line POSIX hash logic should be gone.
|
|
// `cwd.replace(/\//g, '-')` was the entire normalization before #1883.
|
|
const legacy = /const projectHash = cwd\.replace\(\/\\\/\/g,\s*['"]-['"]\);/;
|
|
if (legacy.test(distSrc)) {
|
|
fail('#1883-regression — legacy single-line POSIX-only hash logic is back; resolveProjectMemoryDir was removed or bypassed');
|
|
} else {
|
|
pass('#1883-no-regression — legacy POSIX-only normalization no longer present in dist');
|
|
}
|
|
|
|
// ---------- 2. Property check: sanitizer round-trips through validator ----------
|
|
//
|
|
// Use the same regex literal as the dist — DANGEROUS_KEY_CHARS / _PATTERN
|
|
// share their pattern, so the property test can use one source of truth that
|
|
// matches what shipped (the static-scan section above asserts the dist still
|
|
// declares them).
|
|
|
|
const dangerousChars = /[;&|`$(){}[\]<>!#\\\0]|\.\.[/\\]/g;
|
|
const dangerousPattern = /[;&|`$(){}[\]<>!#\\\0]|\.\.[/\\]/;
|
|
|
|
// Confirm the dist literal we're mirroring is byte-identical to what we declare here.
|
|
const distRegexLine = '/[;&|`$(){}[\\]<>!#\\\\\\0]|\\.\\.[/\\\\]/';
|
|
if (!distSrc.includes('const DANGEROUS_KEY_CHARS = /[;&|`$(){}[\\]<>!#\\\\\\0]|\\.\\.[/\\\\]/g;')) {
|
|
fail('#1884-regex-mirror — DANGEROUS_KEY_CHARS literal in dist no longer matches the test mirror; update both together');
|
|
} else {
|
|
pass('#1884-regex-mirror — DANGEROUS_KEY_CHARS literal in dist matches test mirror');
|
|
}
|
|
|
|
// Mimic sanitizeMemoryKey
|
|
const sanitize = (k) => k.replace(dangerousChars, '_').slice(0, 1024);
|
|
|
|
// Property: every char in the dangerous set, embedded in a realistic import
|
|
// key, must produce a sanitized key that passes the delete-path validator.
|
|
const dangerousSamples = [
|
|
';', '&', '|', '`', '$', '(', ')', '{', '}', '[', ']',
|
|
'<', '>', '!', '#', '\\', '\0', '../', '..\\',
|
|
];
|
|
// Realistic markdown-section-shaped strings
|
|
const realistic = [
|
|
'Foo (bar)!',
|
|
'Section #1',
|
|
'A & B',
|
|
'${malicious}',
|
|
'`shell`',
|
|
'../etc/passwd',
|
|
'name<tag>value',
|
|
'list[0]',
|
|
];
|
|
|
|
let propFail = 0;
|
|
for (const sample of [...dangerousSamples, ...realistic]) {
|
|
const importKey = `claude:project:name:${sample}`;
|
|
const sanitized = sanitize(importKey);
|
|
if (dangerousPattern.test(sanitized)) {
|
|
console.error(` property fail: sanitize(${JSON.stringify(importKey)}) = ${JSON.stringify(sanitized)} still matches DANGEROUS_KEY_PATTERN`);
|
|
propFail++;
|
|
}
|
|
}
|
|
if (propFail === 0) pass(`#1884-property — sanitizer output passes validator for ${dangerousSamples.length + realistic.length} adversarial inputs`);
|
|
else fail(`#1884-property — ${propFail} sanitized key(s) still rejected by validator`);
|
|
|
|
// ---------- 3. WSL fixture check ----------
|
|
//
|
|
// Replicate the candidate-generation loop with a synthetic WSL cwd and
|
|
// assert the Claude-Code Windows hash form is among the candidates.
|
|
|
|
function deriveCandidates(source) {
|
|
const candidates = new Set();
|
|
candidates.add(source.replace(/\//g, '-'));
|
|
const wsl = source.match(/^\/mnt\/([a-z])(\/.*)?$/i);
|
|
if (wsl) {
|
|
const drive = wsl[1].toUpperCase();
|
|
const rest = (wsl[2] ?? '').replace(/\//g, '-').replace(/ /g, '-');
|
|
candidates.add(`${drive}-${rest}`);
|
|
}
|
|
candidates.add(source.replace(/\//g, '-').replace(/^-+/, ''));
|
|
candidates.add(source.replace(/\//g, '-').replace(/ /g, '-'));
|
|
return candidates;
|
|
}
|
|
|
|
const wslCwd = '/mnt/c/Users/tobia/OneDrive/Desktop/Claude Stuff';
|
|
const candidates = deriveCandidates(wslCwd);
|
|
const expectedWindowsHash = 'C--Users-tobia-OneDrive-Desktop-Claude-Stuff';
|
|
if (candidates.has(expectedWindowsHash)) {
|
|
pass(`#1883-wsl-fixture — candidate set includes Claude-Code Windows hash for ${wslCwd}`);
|
|
} else {
|
|
fail(`#1883-wsl-fixture — expected ${expectedWindowsHash} in candidate set, got: ${[...candidates].join(' | ')}`);
|
|
}
|
|
|
|
// macOS/Linux POSIX cwd should still produce the legacy hash in candidate set
|
|
// so existing-deployment compatibility holds.
|
|
const posixCwd = '/Users/alice/projects/ruflo';
|
|
const posixCandidates = deriveCandidates(posixCwd);
|
|
if (posixCandidates.has('-Users-alice-projects-ruflo')) {
|
|
pass(`#1883-posix-compat — legacy POSIX hash still in candidate set for ${posixCwd}`);
|
|
} else {
|
|
fail(`#1883-posix-compat — legacy POSIX hash missing from candidate set; would break existing macOS/Linux installs`);
|
|
}
|
|
|
|
// ---------- Summary ----------
|
|
if (failed > 0) {
|
|
console.error(`\n${failed} regression check(s) failed for #1883/#1884`);
|
|
process.exit(1);
|
|
}
|
|
console.log('\nall #1883/#1884 regression guards green');
|