@claude-flow/cli 3.56.5 → 3.56.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/auto-memory-hook.mjs +109 -10
- package/.claude/helpers/helpers.manifest.json +5 -5
- package/.claude/helpers/hook-handler.cjs +198 -1
- package/.claude/helpers/intelligence.cjs +57 -4
- package/bin/cli.js +11 -1
- package/bin/mcp-server.js +11 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/doctor-native-addon.d.ts +23 -0
- package/dist/src/commands/doctor-native-addon.js +135 -0
- package/dist/src/commands/doctor.js +3 -0
- package/dist/src/commands/hooks.js +8 -1
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init.d.ts +2 -0
- package/dist/src/commands/init.js +37 -5
- package/dist/src/commands/mods.d.ts +3 -0
- package/dist/src/commands/mods.js +19 -5
- package/dist/src/commands/tui.d.ts +12 -0
- package/dist/src/commands/tui.js +98 -0
- package/dist/src/fs-secure.d.ts +25 -0
- package/dist/src/fs-secure.js +51 -2
- package/dist/src/init/executor.js +11 -14
- package/dist/src/init/helper-commands.d.ts +2 -0
- package/dist/src/init/helper-commands.js +42 -11
- package/dist/src/init/helpers-generator.js +4 -1
- package/dist/src/init/settings-generator.js +3 -3
- package/dist/src/init/settings-risk-scanner.js +25 -6
- package/dist/src/mcp-server.js +10 -2
- package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
- package/dist/src/mcp-tools/capability-brain.js +1 -1
- package/dist/src/mcp-tools/hooks-tools.js +41 -15
- package/dist/src/mcp-tools/memory-tools.js +2 -1
- package/dist/src/memory/live-memory-row.d.ts +3 -1
- package/dist/src/memory/live-memory-row.js +3 -1
- package/dist/src/memory/memory-bridge.d.ts +4 -0
- package/dist/src/memory/memory-bridge.js +3 -2
- package/dist/src/memory/memory-initializer.d.ts +4 -0
- package/dist/src/memory/memory-initializer.js +12 -11
- package/dist/src/mods/claude-installs.js +7 -3
- package/dist/src/mods/install.js +32 -12
- package/dist/src/mods/plugin-resolve.js +3 -1
- package/dist/src/mods/probe.js +92 -4
- package/dist/src/services/command-recording.d.ts +17 -0
- package/dist/src/services/command-recording.js +91 -0
- package/node_modules/@claude-flow/codex/README.md +20 -3
- package/node_modules/@claude-flow/codex/dist/cli.js +7 -0
- package/node_modules/@claude-flow/codex/dist/cli.js.map +1 -1
- package/node_modules/@claude-flow/codex/dist/index.d.ts +3 -3
- package/node_modules/@claude-flow/codex/dist/index.js +1 -1
- package/node_modules/@claude-flow/codex/dist/initializer.d.ts +8 -3
- package/node_modules/@claude-flow/codex/dist/initializer.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/dist/initializer.js +75 -13
- package/node_modules/@claude-flow/codex/dist/initializer.js.map +1 -1
- package/node_modules/@claude-flow/codex/dist/types.d.ts +8 -0
- package/node_modules/@claude-flow/codex/dist/types.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/package.json +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/package.json +4 -4
- package/plugins/ruflo-console/dist/tui.mjs +271 -0
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* node auto-memory-hook.mjs status # Show bridge status
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
15
|
-
import {
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, rmdirSync, rmSync, statSync } from 'fs';
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { join, dirname, resolve, sep } from 'path';
|
|
16
18
|
import { fileURLToPath } from 'url';
|
|
17
19
|
|
|
18
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -77,6 +79,66 @@ process.on('SIGINT', () => { gracefulExit('SIGINT'); });
|
|
|
77
79
|
// Ensure data dir
|
|
78
80
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
79
81
|
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Store write safety (#4058): intelligence.cjs read-modify-writes the same
|
|
84
|
+
// file from the session-end hook, so writes are atomic (temp + rename) and
|
|
85
|
+
// serialised by a short directory lock. The lock is advisory and fails open:
|
|
86
|
+
// a hook must never hang, so after ~2 s it proceeds without it.
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
const LOCK_WAIT_MS = 2000;
|
|
90
|
+
const LOCK_STALE_MS = 10000;
|
|
91
|
+
function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
92
|
+
|
|
93
|
+
function withStoreLock(storePath, fn) {
|
|
94
|
+
const lock = storePath + '.lock';
|
|
95
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
96
|
+
let held = false;
|
|
97
|
+
for (;;) {
|
|
98
|
+
try { mkdirSync(lock); held = true; break; } catch (e) {
|
|
99
|
+
if (!e || e.code !== 'EEXIST') break;
|
|
100
|
+
try {
|
|
101
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { rmSync(lock, { recursive: true, force: true }); continue; }
|
|
102
|
+
} catch { /* lock vanished: retry */ }
|
|
103
|
+
if (Date.now() > deadline) break;
|
|
104
|
+
sleepSync(10);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try { return fn(); } finally { if (held) { try { rmdirSync(lock); } catch { /* already gone */ } } }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeFileAtomic(filePath, text) {
|
|
111
|
+
const tmp = filePath + '.' + process.pid + '.tmp';
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(tmp, text, 'utf-8');
|
|
114
|
+
renameSync(tmp, filePath);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Claude Code shares one auto-memory directory across every worktree of a
|
|
122
|
+
// repository, but findGitRoot() in the memory package stops at a worktree's
|
|
123
|
+
// `.git` FILE and keys the directory by the worktree path (#4058). Resolve the
|
|
124
|
+
// main checkout through git's common dir so both agree. No shell is involved.
|
|
125
|
+
function mainCheckoutRoot(dir) {
|
|
126
|
+
try {
|
|
127
|
+
if (!statSync(join(dir, '.git')).isFile()) return null;
|
|
128
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: dir, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
129
|
+
if (!common) return null;
|
|
130
|
+
const abs = resolve(dir, common);
|
|
131
|
+
return abs.endsWith('/.git') || abs.endsWith('\\.git') ? dirname(abs) : null;
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function worktreeMemoryDir() {
|
|
136
|
+
const main = mainCheckoutRoot(PROJECT_ROOT);
|
|
137
|
+
if (!main) return undefined;
|
|
138
|
+
const key = main.split(sep).join('/').replace(/[\/_:]/g, '-');
|
|
139
|
+
return join(process.env.HOME || process.env.USERPROFILE || homedir(), '.claude', 'projects', key, 'memory');
|
|
140
|
+
}
|
|
141
|
+
|
|
80
142
|
// ============================================================================
|
|
81
143
|
// Simple JSON File Backend (implements IMemoryBackend interface)
|
|
82
144
|
// ============================================================================
|
|
@@ -85,6 +147,8 @@ class JsonFileBackend {
|
|
|
85
147
|
constructor(filePath) {
|
|
86
148
|
this.filePath = filePath;
|
|
87
149
|
this.entries = new Map();
|
|
150
|
+
this.loadedIds = new Set();
|
|
151
|
+
this.deletedIds = new Set();
|
|
88
152
|
}
|
|
89
153
|
|
|
90
154
|
async initialize() {
|
|
@@ -92,7 +156,7 @@ class JsonFileBackend {
|
|
|
92
156
|
try {
|
|
93
157
|
const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
94
158
|
if (Array.isArray(data)) {
|
|
95
|
-
for (const entry of data) this.entries.set(entry.id, entry);
|
|
159
|
+
for (const entry of data) { this.entries.set(entry.id, entry); this.loadedIds.add(entry.id); }
|
|
96
160
|
}
|
|
97
161
|
} catch { /* start fresh */ }
|
|
98
162
|
}
|
|
@@ -117,17 +181,26 @@ class JsonFileBackend {
|
|
|
117
181
|
this._persist();
|
|
118
182
|
return e;
|
|
119
183
|
}
|
|
120
|
-
async delete(id) { return this.entries.delete(id); }
|
|
184
|
+
async delete(id) { this.deletedIds.add(id); return this.entries.delete(id); }
|
|
121
185
|
async query(opts) {
|
|
122
186
|
let results = [...this.entries.values()];
|
|
123
187
|
if (opts?.namespace) results = results.filter(e => e.namespace === opts.namespace);
|
|
124
|
-
|
|
188
|
+
// MemoryQuery.type is the query mode (semantic|exact|prefix|tag|hybrid), not
|
|
189
|
+
// the entry's memory type: the bridge asks for { type: 'hybrid' } and a strict
|
|
190
|
+
// entry-type match returned nothing, so sync always reported 0 (#4058).
|
|
191
|
+
const MODES = ['semantic', 'exact', 'prefix', 'tag', 'hybrid'];
|
|
192
|
+
if (opts?.type && !MODES.includes(opts.type)) results = results.filter(e => e.type === opts.type);
|
|
193
|
+
if (opts?.memoryType) results = results.filter(e => e.type === opts.memoryType);
|
|
194
|
+
if (opts?.key) results = results.filter(e => e.key === opts.key);
|
|
195
|
+
if (opts?.keyPrefix) results = results.filter(e => typeof e.key === 'string' && e.key.startsWith(opts.keyPrefix));
|
|
196
|
+
if (Array.isArray(opts?.tags) && opts.tags.length) results = results.filter(e => opts.tags.every(t => (e.tags || []).includes(t)));
|
|
197
|
+
if (opts?.updatedAfter) results = results.filter(e => (e.updatedAt || 0) > opts.updatedAfter);
|
|
125
198
|
if (opts?.limit) results = results.slice(0, opts.limit);
|
|
126
199
|
return results;
|
|
127
200
|
}
|
|
128
201
|
async search() { return []; } // No vector search in JSON backend
|
|
129
202
|
async bulkInsert(entries) { for (const e of entries) this.entries.set(e.id, e); this._persist(); }
|
|
130
|
-
async bulkDelete(ids) { let n = 0; for (const id of ids) { if (this.entries.delete(id)) n++; } this._persist(); return n; }
|
|
203
|
+
async bulkDelete(ids) { let n = 0; for (const id of ids) { this.deletedIds.add(id); if (this.entries.delete(id)) n++; } this._persist(); return n; }
|
|
131
204
|
async count() { return this.entries.size; }
|
|
132
205
|
async listNamespaces() {
|
|
133
206
|
const ns = new Set();
|
|
@@ -137,7 +210,7 @@ class JsonFileBackend {
|
|
|
137
210
|
async clearNamespace(ns) {
|
|
138
211
|
let n = 0;
|
|
139
212
|
for (const [id, e] of this.entries) {
|
|
140
|
-
if (e.namespace === ns) { this.entries.delete(id); n++; }
|
|
213
|
+
if (e.namespace === ns) { this.deletedIds.add(id); this.entries.delete(id); n++; }
|
|
141
214
|
}
|
|
142
215
|
this._persist();
|
|
143
216
|
return n;
|
|
@@ -164,7 +237,22 @@ class JsonFileBackend {
|
|
|
164
237
|
|
|
165
238
|
_persist() {
|
|
166
239
|
try {
|
|
167
|
-
|
|
240
|
+
withStoreLock(this.filePath, () => {
|
|
241
|
+
// Keep entries another writer (intelligence.cjs, a parallel hook) added
|
|
242
|
+
// after we loaded; ours win on the same id, and our own deletes stay deleted.
|
|
243
|
+
const merged = new Map(this.entries);
|
|
244
|
+
try {
|
|
245
|
+
const onDisk = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
246
|
+
if (Array.isArray(onDisk)) {
|
|
247
|
+
for (const e of onDisk) {
|
|
248
|
+
if (e && !merged.has(e.id) && !this.loadedIds.has(e.id) && !this.deletedIds.has(e.id)) merged.set(e.id, e);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch { /* missing or unreadable: write ours */ }
|
|
252
|
+
writeFileAtomic(this.filePath, JSON.stringify([...merged.values()], null, 2));
|
|
253
|
+
for (const id of merged.keys()) this.loadedIds.add(id);
|
|
254
|
+
this.entries = merged;
|
|
255
|
+
});
|
|
168
256
|
} catch { /* best effort */ }
|
|
169
257
|
}
|
|
170
258
|
}
|
|
@@ -284,6 +372,8 @@ async function doImport() {
|
|
|
284
372
|
workingDir: PROJECT_ROOT,
|
|
285
373
|
syncMode: 'on-session-end',
|
|
286
374
|
};
|
|
375
|
+
const wtDir = worktreeMemoryDir();
|
|
376
|
+
if (wtDir) bridgeConfig.memoryDir = wtDir;
|
|
287
377
|
|
|
288
378
|
// Wire learning if enabled and available
|
|
289
379
|
if (config.learningBridge.enabled && memPkg.LearningBridge) {
|
|
@@ -344,6 +434,8 @@ async function doSync() {
|
|
|
344
434
|
workingDir: PROJECT_ROOT,
|
|
345
435
|
syncMode: 'on-session-end',
|
|
346
436
|
};
|
|
437
|
+
const wtDir = worktreeMemoryDir();
|
|
438
|
+
if (wtDir) bridgeConfig.memoryDir = wtDir;
|
|
347
439
|
|
|
348
440
|
if (config.learningBridge.enabled && memPkg.LearningBridge) {
|
|
349
441
|
bridgeConfig.learning = {
|
|
@@ -369,8 +461,11 @@ async function doSync() {
|
|
|
369
461
|
dim(`└─ Backend entries: ${entryCount}`);
|
|
370
462
|
|
|
371
463
|
// Curate MEMORY.md index with graph-aware ordering
|
|
464
|
+
let indexSkipped = null;
|
|
465
|
+
if (typeof bridge.on === 'function') bridge.on('index:skipped', (info) => { indexSkipped = info; });
|
|
372
466
|
await bridge.curateIndex();
|
|
373
|
-
|
|
467
|
+
if (indexSkipped) dim(`MEMORY.md index unchanged (${indexSkipped.reason || 'nothing to curate'})`);
|
|
468
|
+
else success('Curated MEMORY.md index');
|
|
374
469
|
} catch (err) {
|
|
375
470
|
dim(`Sync failed (non-critical): ${err.message}`);
|
|
376
471
|
}
|
|
@@ -408,7 +503,11 @@ async function doStatus() {
|
|
|
408
503
|
// Main
|
|
409
504
|
// ============================================================================
|
|
410
505
|
|
|
411
|
-
|
|
506
|
+
// Older settings.json files run `node -e "…import(file)" import`, where the
|
|
507
|
+
// subcommand lands in argv[1] and argv[2] is empty, so both hooks fell through
|
|
508
|
+
// to `status` and never imported or synced (#4058). Accept that shape too.
|
|
509
|
+
const COMMANDS = ['import', 'sync', 'status'];
|
|
510
|
+
const command = process.argv[2] || (COMMANDS.includes(process.argv[1]) ? process.argv[1] : 'status');
|
|
412
511
|
|
|
413
512
|
// Dynamic import() failures can surface as unhandled rejections on a later
|
|
414
513
|
// microtask even when the awaiting call site already caught them, which would
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.56.
|
|
3
|
+
"version": "3.56.7",
|
|
4
4
|
"files": {
|
|
5
|
-
"auto-memory-hook.mjs": "
|
|
6
|
-
"hook-handler.cjs": "
|
|
7
|
-
"intelligence.cjs": "
|
|
5
|
+
"auto-memory-hook.mjs": "631fac9d518835e2852a406888316bbbddddb49e7ed8e406d2e971f46833df9a",
|
|
6
|
+
"hook-handler.cjs": "0f855c667b8d2d04cb4fe91b14b351d14c3dfb30516dcd9d7ae156acbe0a689d",
|
|
7
|
+
"intelligence.cjs": "f486be296eb2f0518f34b8f4453d9d0a988845e4aec6d18d8d47725aca6dd38b",
|
|
8
8
|
"statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
|
|
9
9
|
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
|
-
"signature": "/
|
|
12
|
+
"signature": "15NFpUZ8+naP4v/xuCBCl7IZ//YJPXQYotOg48V/Xw/uyCY1FXYeeL4a3Rwa+Bji0+CV4FRiZg61N6Zt+EStBg==",
|
|
13
13
|
"algorithm": "ed25519"
|
|
14
14
|
}
|
|
@@ -213,6 +213,198 @@ function spawnDetachedAdvisorRefresh() {
|
|
|
213
213
|
spawnDetachedHookRefresh('refresh-advisor');
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// ── Settings-risk scan for the automatic SessionStart hook ────────────────
|
|
217
|
+
//
|
|
218
|
+
// Mirrors v3/@claude-flow/cli/src/init/settings-risk-scanner.ts's
|
|
219
|
+
// `scanSettingsForRisk()`/`formatRiskFindingsAsWarnings()`. That TS scanner
|
|
220
|
+
// already runs at `ruflo init`/`--upgrade` time and (via the
|
|
221
|
+
// `hooks_session-restore` MCP/CLI tool handler) on an explicit
|
|
222
|
+
// `session-restore` call — but neither covers the one path that actually
|
|
223
|
+
// fires automatically, unconditionally, on every real Claude Code session
|
|
224
|
+
// boot: THIS file, invoked as the `SessionStart` hook configured in
|
|
225
|
+
// .claude/settings.json itself. That is precisely the persistence mechanism
|
|
226
|
+
// used by the real 2026 "Mini Shai-Hulud" npm-worm campaign (a malicious
|
|
227
|
+
// SessionStart hook written into settings.json re-fires on every session,
|
|
228
|
+
// surviving removal of the original infected package) and the escalation
|
|
229
|
+
// path closed by CVE-2026-25725 in Claude Code's own sandbox. hook-handler.cjs
|
|
230
|
+
// has no build step and cannot import the TS module, so this is a deliberate
|
|
231
|
+
// CJS port — keep the pattern lists in sync with settings-risk-scanner.ts;
|
|
232
|
+
// see the logic-parity test guarding this.
|
|
233
|
+
//
|
|
234
|
+
// Advisory-only: never mutates settings.json, never blocks session-restore.
|
|
235
|
+
// A missing settings.json or any JSON.parse failure yields zero findings
|
|
236
|
+
// silently. Unlike the opt-in `hooks_session-restore` MCP-tool path (#3835,
|
|
237
|
+
// which narrows its catch to SyntaxError only so a real scanner bug
|
|
238
|
+
// surfaces), THIS path fires unconditionally on every real session boot —
|
|
239
|
+
// an adversarial review of this exact candidate flagged that narrowing the
|
|
240
|
+
// same way here would let a non-SyntaxError JSON.parse failure (e.g. a V8
|
|
241
|
+
// RangeError on deeply-nested JSON) abort session-restore itself, not just
|
|
242
|
+
// skip the scan. "Can't scan it" and "can't parse it" are the same case
|
|
243
|
+
// here, so any parse failure degrades to zero findings.
|
|
244
|
+
const SR_DANGEROUS_COMMAND_WORDS = [
|
|
245
|
+
'rm', 'rmdir', 'del', 'format', 'mkfs', 'dd', 'chmod', 'chown',
|
|
246
|
+
'kill', 'killall', 'pkill', 'reboot', 'shutdown', 'poweroff', 'halt',
|
|
247
|
+
'sudo', 'eval',
|
|
248
|
+
];
|
|
249
|
+
const SR_SHELL_EXEC_WORDS = ['bash', 'sh', 'zsh', 'python', 'python3', 'perl', 'ruby', 'eval'];
|
|
250
|
+
const SR_DOWNLOADER_WORDS = ['curl', 'wget'];
|
|
251
|
+
const SR_STANDALONE_RISK_PATTERNS = [
|
|
252
|
+
/\bInvoke-Expression\b/i,
|
|
253
|
+
/\biex\s*\(/i,
|
|
254
|
+
/\/dev\/tcp\//i,
|
|
255
|
+
/\bnc\s+-e\b/i,
|
|
256
|
+
/base64\s+(-d|--decode)\b/i,
|
|
257
|
+
];
|
|
258
|
+
const SR_RISKY_PREAPPROVE_WORDS = [...SR_DANGEROUS_COMMAND_WORDS, ...SR_SHELL_EXEC_WORDS, ...SR_DOWNLOADER_WORDS];
|
|
259
|
+
|
|
260
|
+
// Encoded PowerShell (`powershell[.exe] ... -e|-enc|-encodedcommand <payload>`),
|
|
261
|
+
// matched on whitespace-separated tokens in ONE linear pass. The earlier regex
|
|
262
|
+
// (`powershell\\s+(?:\\S+\\s+)*?-e...`) is quadratic on repeated "powershell "
|
|
263
|
+
// tokens (every start rescans the tail: 256KB measured ~2.5s, 1MB ~40s) because
|
|
264
|
+
// the lazy token loop re-runs from each start. Same semantics: a token ending in
|
|
265
|
+
// powershell/powershell.exe, then LATER a token that is exactly -e/-enc/
|
|
266
|
+
// -encodedcommand, then at least one more token.
|
|
267
|
+
function srHasEncodedPowershell(command) {
|
|
268
|
+
let seenPs = false;
|
|
269
|
+
let pendingEnc = false;
|
|
270
|
+
for (const raw of String(command).split(/\s+/)) {
|
|
271
|
+
if (!raw) continue;
|
|
272
|
+
if (pendingEnc) return true;
|
|
273
|
+
const t = raw.toLowerCase();
|
|
274
|
+
if (seenPs && (t === '-e' || t === '-enc' || t === '-encodedcommand')) pendingEnc = true;
|
|
275
|
+
else if (t.endsWith('powershell') || t.endsWith('powershell.exe')) seenPs = true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function srWordBoundaryMatches(text, word) {
|
|
281
|
+
return new RegExp(`\\b${word}\\b`, 'i').test(text);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function srScanCommandStringForRisk(command) {
|
|
285
|
+
const reasons = [];
|
|
286
|
+
if (srHasEncodedPowershell(command) || SR_STANDALONE_RISK_PATTERNS.some((pattern) => pattern.test(command))) {
|
|
287
|
+
reasons.push('matches a known-dangerous execution pattern (encoded/remote command execution)');
|
|
288
|
+
}
|
|
289
|
+
const hasDownloader = SR_DOWNLOADER_WORDS.some((w) => srWordBoundaryMatches(command, w));
|
|
290
|
+
const hasShellExec = SR_SHELL_EXEC_WORDS.some((w) => srWordBoundaryMatches(command, w));
|
|
291
|
+
if (hasDownloader && hasShellExec) {
|
|
292
|
+
reasons.push('combines a downloader (curl/wget) with a shell/interpreter invocation in the same command');
|
|
293
|
+
}
|
|
294
|
+
for (const word of SR_DANGEROUS_COMMAND_WORDS) {
|
|
295
|
+
if (srWordBoundaryMatches(command, word)) {
|
|
296
|
+
reasons.push(`invokes dangerous command "${word}"`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return reasons;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function srScanAllowRuleForRisk(rule) {
|
|
303
|
+
const trimmed = rule.trim();
|
|
304
|
+
const reasons = [];
|
|
305
|
+
if (/^Bash\(\s*\*\s*(:\s*\*\s*)?\)$/i.test(trimmed)) {
|
|
306
|
+
reasons.push('grants unrestricted Bash execution (wildcard allow rule)');
|
|
307
|
+
}
|
|
308
|
+
for (const word of SR_RISKY_PREAPPROVE_WORDS) {
|
|
309
|
+
if (new RegExp(`^Bash\\(\\s*${word}\\s*(:| |\\))`, 'i').test(trimmed)) {
|
|
310
|
+
reasons.push(`pre-allows "${word}" with no per-call confirmation`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return reasons;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Strips ANSI/control characters from untrusted text before it's ever
|
|
317
|
+
// embedded in a warning string printed to the terminal (same rationale as
|
|
318
|
+
// settings-risk-scanner.ts's sanitizeForDisplay()).
|
|
319
|
+
function srSanitizeForDisplay(text) {
|
|
320
|
+
// eslint-disable-next-line no-control-regex
|
|
321
|
+
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function srBuildSnippet(text) {
|
|
325
|
+
const clean = srSanitizeForDisplay(text);
|
|
326
|
+
return clean.length > 120 ? `${clean.slice(0, 120)}…` : clean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function srScanSettingsForRisk(settings) {
|
|
330
|
+
const findings = [];
|
|
331
|
+
const hooks = settings && settings.hooks;
|
|
332
|
+
if (hooks && typeof hooks === 'object') {
|
|
333
|
+
for (const [eventName, groups] of Object.entries(hooks)) {
|
|
334
|
+
if (!Array.isArray(groups)) continue;
|
|
335
|
+
groups.forEach((group, groupIdx) => {
|
|
336
|
+
const hookList = group && group.hooks;
|
|
337
|
+
if (!Array.isArray(hookList)) return;
|
|
338
|
+
hookList.forEach((h, hookIdx) => {
|
|
339
|
+
if (!h || typeof h.command !== 'string') return;
|
|
340
|
+
for (const reason of srScanCommandStringForRisk(h.command)) {
|
|
341
|
+
findings.push({
|
|
342
|
+
location: `hooks.${eventName}[${groupIdx}].hooks[${hookIdx}]`,
|
|
343
|
+
snippet: srBuildSnippet(h.command),
|
|
344
|
+
reason,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const allow = settings && settings.permissions && settings.permissions.allow;
|
|
352
|
+
if (Array.isArray(allow)) {
|
|
353
|
+
allow.forEach((rule, idx) => {
|
|
354
|
+
if (typeof rule !== 'string') return;
|
|
355
|
+
for (const reason of srScanAllowRuleForRisk(rule)) {
|
|
356
|
+
findings.push({
|
|
357
|
+
location: `permissions.allow[${idx}]`,
|
|
358
|
+
snippet: srBuildSnippet(rule),
|
|
359
|
+
reason,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return findings;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function srFormatRiskFindingsAsWarnings(findings) {
|
|
368
|
+
return findings.map((f) => `${f.location}: ${f.reason} — "${f.snippet}"`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Location + reason only, no snippet — see scanSessionStartSettingsForRisk's
|
|
372
|
+
// doc comment for why the automatic boot path deliberately drops it.
|
|
373
|
+
function srFormatRiskFindingsForAutomaticBoot(findings) {
|
|
374
|
+
return findings.map((f) => `${f.location}: ${f.reason}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Reads and scans THIS project's own .claude/settings.json. Returns a
|
|
378
|
+
// (possibly empty) array of warning strings; never throws on a
|
|
379
|
+
// missing/malformed file.
|
|
380
|
+
//
|
|
381
|
+
// Deliberately omits the matched snippet (unlike srFormatRiskFindingsAsWarnings,
|
|
382
|
+
// which `init`/`--upgrade` uses and which DOES include it): this function's
|
|
383
|
+
// only caller is the 'session-restore' handler below, whose stdout is read
|
|
384
|
+
// back into the live Claude Code session as context — not a human reading a
|
|
385
|
+
// terminal. Printing up to 120 characters of attacker-controlled settings.json
|
|
386
|
+
// content into that context is a prompt-injection path even with control
|
|
387
|
+
// characters stripped (flagged by review on PR #4053). The location and
|
|
388
|
+
// static reason string are both scanner-generated, never attacker content.
|
|
389
|
+
function scanSessionStartSettingsForRisk() {
|
|
390
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
391
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
392
|
+
let raw;
|
|
393
|
+
try {
|
|
394
|
+
raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
395
|
+
} catch (e) {
|
|
396
|
+
return []; // no settings.json (or unreadable) — nothing to scan
|
|
397
|
+
}
|
|
398
|
+
let settings;
|
|
399
|
+
try {
|
|
400
|
+
settings = JSON.parse(raw);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
return []; // can't parse it, so can't scan it — never block session-restore for this
|
|
403
|
+
}
|
|
404
|
+
if (!settings || typeof settings !== 'object') return [];
|
|
405
|
+
return srFormatRiskFindingsForAutomaticBoot(srScanSettingsForRisk(settings));
|
|
406
|
+
}
|
|
407
|
+
|
|
216
408
|
// Safe require with stdout suppression - the helper modules have CLI
|
|
217
409
|
// sections that run unconditionally on require(), so we mute console
|
|
218
410
|
// during the require to prevent noisy output.
|
|
@@ -651,6 +843,11 @@ const handlers = {
|
|
|
651
843
|
// Fully non-blocking (detached spawn) so session-restore latency
|
|
652
844
|
// is unchanged.
|
|
653
845
|
firstRunAutoEnableIfEligible();
|
|
846
|
+
// Advisory-only settings.json risk scan — see scanSessionStartSettingsForRisk's
|
|
847
|
+
// own doc comment for why this runs specifically on this automatic path.
|
|
848
|
+
for (const warning of scanSessionStartSettingsForRisk()) {
|
|
849
|
+
console.log(`[WARN] ${warning}`);
|
|
850
|
+
}
|
|
654
851
|
if (session) {
|
|
655
852
|
// Try restore first, fall back to start
|
|
656
853
|
const existing = session.restore && session.restore();
|
|
@@ -777,4 +974,4 @@ if (require.main === module) {
|
|
|
777
974
|
// Which sibling helpers loaded (all CommonJS, shipped as .cjs — #3555).
|
|
778
975
|
const loadedHelpers = { router: !!router, session: !!session, memory: !!memory, intelligence: !!intelligence };
|
|
779
976
|
|
|
780
|
-
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot };
|
|
977
|
+
module.exports = { runWithTimeout, INTELLIGENCE_TIMEOUT_MS, loadedHelpers, ownedByMod, MOD_OWNABLE_EVENTS, resolveCliBinForHook, cliSpawnArgs, helperRoot, scanSessionStartSettingsForRisk, srFormatRiskFindingsForAutomaticBoot, srScanSettingsForRisk, srScanCommandStringForRisk, srScanAllowRuleForRisk, SR_DANGEROUS_COMMAND_WORDS, SR_SHELL_EXEC_WORDS, SR_DOWNLOADER_WORDS, SR_STANDALONE_RISK_PATTERNS };
|
|
@@ -108,9 +108,60 @@ function readJSON(filePath) {
|
|
|
108
108
|
return null;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Atomic (temp + rename): auto-memory-hook.mjs reads and writes the same store
|
|
112
|
+
// from the session hooks, and a reader must never see a half-written file (#4058).
|
|
111
113
|
function writeJSON(filePath, data) {
|
|
112
114
|
ensureDataDir();
|
|
113
|
-
|
|
115
|
+
const tmp = filePath + '.' + process.pid + '.tmp';
|
|
116
|
+
try {
|
|
117
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
118
|
+
fs.renameSync(tmp, filePath);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Short advisory directory lock shared with auto-memory-hook.mjs. It fails open
|
|
126
|
+
// after ~2 s so a stuck lock can never hang a hook.
|
|
127
|
+
function withStoreLock(filePath, fn) {
|
|
128
|
+
const lock = filePath + '.lock';
|
|
129
|
+
const deadline = Date.now() + 2000;
|
|
130
|
+
let held = false;
|
|
131
|
+
for (;;) {
|
|
132
|
+
try { fs.mkdirSync(lock); held = true; break; } catch (e) {
|
|
133
|
+
if (!e || e.code !== 'EEXIST') break;
|
|
134
|
+
try {
|
|
135
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 10000) { fs.rmSync(lock, { recursive: true, force: true }); continue; }
|
|
136
|
+
} catch { /* lock vanished: retry */ }
|
|
137
|
+
if (Date.now() > deadline) break;
|
|
138
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
try { return fn(); } finally { if (held) { try { fs.rmdirSync(lock); } catch { /* already gone */ } } }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// IDs present when the store was read. Take this BEFORE deduping: those passes
|
|
145
|
+
// assign IDs to entries in place, and an entry that had none must still count as
|
|
146
|
+
// ours (not as something another writer added).
|
|
147
|
+
function storeIds(loaded) {
|
|
148
|
+
return new Set((Array.isArray(loaded) ? loaded : []).map((e) => e && e.id));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Write the store, keeping entries another writer added after we read it.
|
|
152
|
+
function writeStoreMerged(filePath, next, loadedIds) {
|
|
153
|
+
withStoreLock(filePath, () => {
|
|
154
|
+
let out = next;
|
|
155
|
+
try {
|
|
156
|
+
const current = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
157
|
+
if (Array.isArray(current)) {
|
|
158
|
+
const have = new Set(next.map((e) => e && e.id));
|
|
159
|
+
const added = current.filter((e) => e && e.id != null && !loadedIds.has(e.id) && !have.has(e.id));
|
|
160
|
+
if (added.length) out = next.concat(added);
|
|
161
|
+
}
|
|
162
|
+
} catch { /* missing or unreadable: write ours */ }
|
|
163
|
+
writeJSON(filePath, out);
|
|
164
|
+
});
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
function tokenize(text) {
|
|
@@ -475,13 +526,14 @@ function init() {
|
|
|
475
526
|
// Check if graph-state.json is fresh (within 60s of store)
|
|
476
527
|
const graphState = readJSON(GRAPH_PATH);
|
|
477
528
|
let store = readJSON(STORE_PATH);
|
|
529
|
+
const loadedIds = storeIds(store);
|
|
478
530
|
|
|
479
531
|
// Bootstrap from MEMORY.md files if store is empty
|
|
480
532
|
if (!store || !Array.isArray(store) || store.length === 0) {
|
|
481
533
|
const bootstrapped = bootstrapFromMemoryFiles();
|
|
482
534
|
if (bootstrapped.length > 0) {
|
|
483
535
|
store = bootstrapped;
|
|
484
|
-
|
|
536
|
+
writeStoreMerged(STORE_PATH, store, loadedIds);
|
|
485
537
|
} else {
|
|
486
538
|
return { nodes: 0, edges: 0, message: 'No memory entries to index' };
|
|
487
539
|
}
|
|
@@ -503,7 +555,7 @@ function init() {
|
|
|
503
555
|
`[INTELLIGENCE] Deduped store: ${store.length} -> ${deduped.length} entries ` +
|
|
504
556
|
`(by-id: ${store.length - beforeContentDedup} dropped, by-content: ${beforeContentDedup - deduped.length} dropped)\n`
|
|
505
557
|
);
|
|
506
|
-
|
|
558
|
+
writeStoreMerged(STORE_PATH, deduped, loadedIds);
|
|
507
559
|
}
|
|
508
560
|
|
|
509
561
|
// Skip rebuild if graph is fresh and store hasn't changed. #2920: nodeCount
|
|
@@ -834,6 +886,7 @@ function consolidate() {
|
|
|
834
886
|
return { entries: 0, edges: 0, newEntries: 0, message: 'No store to consolidate' };
|
|
835
887
|
}
|
|
836
888
|
|
|
889
|
+
const loadedIds = storeIds(store);
|
|
837
890
|
// Deduplicate store entries by ID before processing (fixes #1518)
|
|
838
891
|
const preDedupCount = store.length;
|
|
839
892
|
store = deduplicateById(store);
|
|
@@ -979,7 +1032,7 @@ function consolidate() {
|
|
|
979
1032
|
// RANKED_PATH are rewritten unconditionally just above; writing the
|
|
980
1033
|
// (already deduped, size-bounded per ADR-095 G6) store alongside them
|
|
981
1034
|
// costs nothing near the <500ms budget and removes the whole bug class.
|
|
982
|
-
|
|
1035
|
+
writeStoreMerged(STORE_PATH, store, loadedIds);
|
|
983
1036
|
|
|
984
1037
|
// 9. Save snapshot for delta tracking
|
|
985
1038
|
const updatedGraph = readJSON(GRAPH_PATH);
|
package/bin/cli.js
CHANGED
|
@@ -257,7 +257,7 @@ if (isMCPMode) {
|
|
|
257
257
|
serverInfo: { name: 'ruflo', version: VERSION },
|
|
258
258
|
capabilities: {
|
|
259
259
|
tools: { listChanged: true },
|
|
260
|
-
resources: {
|
|
260
|
+
resources: {},
|
|
261
261
|
},
|
|
262
262
|
},
|
|
263
263
|
};
|
|
@@ -322,6 +322,16 @@ if (isMCPMode) {
|
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
// A declared capability must be served (ADR-490): no resources are exposed, so list answers are empty and a read is "not found".
|
|
326
|
+
case 'resources/list':
|
|
327
|
+
return { jsonrpc: '2.0', id: message.id, result: { resources: [] } };
|
|
328
|
+
|
|
329
|
+
case 'resources/templates/list':
|
|
330
|
+
return { jsonrpc: '2.0', id: message.id, result: { resourceTemplates: [] } };
|
|
331
|
+
|
|
332
|
+
case 'resources/read':
|
|
333
|
+
return { jsonrpc: '2.0', id: message.id, error: { code: -32002, message: 'Resource not found' } };
|
|
334
|
+
|
|
325
335
|
case 'notifications/initialized':
|
|
326
336
|
return null;
|
|
327
337
|
|
package/bin/mcp-server.js
CHANGED
|
@@ -146,7 +146,7 @@ async function handleMessage(message) {
|
|
|
146
146
|
serverInfo: { name: 'ruflo', version: VERSION },
|
|
147
147
|
capabilities: {
|
|
148
148
|
tools: { listChanged: true },
|
|
149
|
-
resources: {
|
|
149
|
+
resources: {},
|
|
150
150
|
},
|
|
151
151
|
},
|
|
152
152
|
};
|
|
@@ -208,6 +208,16 @@ async function handleMessage(message) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// A declared capability must be served (ADR-490): no resources are exposed, so list answers are empty and a read is "not found".
|
|
212
|
+
case 'resources/list':
|
|
213
|
+
return { jsonrpc: '2.0', id: message.id, result: { resources: [] } };
|
|
214
|
+
|
|
215
|
+
case 'resources/templates/list':
|
|
216
|
+
return { jsonrpc: '2.0', id: message.id, result: { resourceTemplates: [] } };
|
|
217
|
+
|
|
218
|
+
case 'resources/read':
|
|
219
|
+
return { jsonrpc: '2.0', id: message.id, error: { code: -32002, message: 'Resource not found' } };
|
|
220
|
+
|
|
211
221
|
case 'notifications/initialized':
|
|
212
222
|
console.error(`[${new Date().toISOString()}] INFO [claude-flow-mcp] Client initialized`);
|
|
213
223
|
return null; // No response for notifications
|
package/catalog-manifest.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface NativeAddonCheck {
|
|
2
|
+
name: string;
|
|
3
|
+
status: 'pass' | 'warn' | 'fail';
|
|
4
|
+
message: string;
|
|
5
|
+
fix?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Node 24.19.0 up to, but not including, 24.22.0. Re-check this bound when a release ships the fix. */
|
|
8
|
+
export declare function isAffectedNodeVersion(version: string): boolean;
|
|
9
|
+
/** The Node version of the headers a copy was built against, from its config.gypi. */
|
|
10
|
+
export declare function headersVersionFromGypi(gypi: string): string | null;
|
|
11
|
+
/** `better-sqlite3` copies in node_modules next to, or one level under, each ancestor of the start dirs. */
|
|
12
|
+
export declare function findBetterSqlite3Copies(startDirs: string[], maxDepth?: number): string[];
|
|
13
|
+
export interface NativeAddonCheckDeps {
|
|
14
|
+
/** Runtime version, default process.versions.node. */
|
|
15
|
+
nodeVersion?: string;
|
|
16
|
+
/** Exact better-sqlite3 package directories to inspect (skips discovery). */
|
|
17
|
+
copies?: string[];
|
|
18
|
+
/** Directories to walk up from when discovering copies. */
|
|
19
|
+
startDirs?: string[];
|
|
20
|
+
}
|
|
21
|
+
/** Read-only: warn when a locally compiled better-sqlite3 was built against Node >= 24.19 headers on an affected runtime. */
|
|
22
|
+
export declare function checkNativeAddonHeaders(deps?: NativeAddonCheckDeps): Promise<NativeAddonCheck>;
|
|
23
|
+
//# sourceMappingURL=doctor-native-addon.d.ts.map
|