@claude-flow/cli 3.56.4 → 3.56.6
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/helpers.manifest.json +3 -3
- package/.claude/helpers/hook-handler.cjs +198 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/init/helper-commands.js +40 -11
- package/dist/src/init/settings-risk-scanner.js +25 -6
- package/dist/src/mcp-tools/hooks-tools.js +15 -11
- package/dist/src/memory/graph-edge-writer.js +33 -11
- package/package.json +3 -3
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.56.
|
|
3
|
+
"version": "3.56.6",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "381412515fb490def7d821ddfe1ebf8d9bee76e49a86e182806c0274f568bf15",
|
|
6
|
-
"hook-handler.cjs": "
|
|
6
|
+
"hook-handler.cjs": "0f855c667b8d2d04cb4fe91b14b351d14c3dfb30516dcd9d7ae156acbe0a689d",
|
|
7
7
|
"intelligence.cjs": "ac5033a77bcea9796856629d5d319bde9ab6b7edf0dcd0d4af187ce30af90836",
|
|
8
8
|
"statusline.cjs": "d72417ecb38634011a19f095a30e07b9929e6f97663520172ece9be7491b2eaa",
|
|
9
9
|
"router.cjs": "16648b3c0d73b6681ac2d8d13def6a68286c57f5a11004de5b19a0fa088c833d"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
|
-
"signature": "
|
|
12
|
+
"signature": "3wmXMrroE4WEmOLGOLYSxXlcB2GITYHa8xpd3Jwu/xLCyFsf+saWI4Vq8EsvCzuwC8u3XpKX7aWrvQchQK3oAg==",
|
|
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 };
|
package/catalog-manifest.json
CHANGED
|
@@ -30,20 +30,38 @@ export function helperScopeFor(targetDir) {
|
|
|
30
30
|
return 'project';
|
|
31
31
|
return canonical(targetDir) === canonical(os.homedir()) ? 'user' : 'project';
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Windows hook command (#4045). It must not go through `cmd /c`: under Git Bash
|
|
35
|
+
* MSYS rewrites `/c` into a path, cmd starts interactively and executes the
|
|
36
|
+
* hook's stdin JSON, and any `>` in that text creates a file. A single
|
|
37
|
+
* `node -e` has no shell syntax to misread (no cmd, no `%VAR%`, no `<>|&$` and
|
|
38
|
+
* no inner double quotes), so cmd, PowerShell and Git Bash pass it through
|
|
39
|
+
* unchanged. The script and its subcommand stay in one string, because the
|
|
40
|
+
* ruflo-mods ownership rule matches `hook-handler.cjs <subcommand>`. Project
|
|
41
|
+
* scope prefers the project's helper and falls back to the user profile's
|
|
42
|
+
* (#1943); user scope never looks at the project. The helper runs as a child
|
|
43
|
+
* with inherited stdio so stdin, stdout and the exit status pass through (a
|
|
44
|
+
* `require` would skip the helper's `require.main === module` entry point).
|
|
45
|
+
*/
|
|
46
|
+
function windowsHookCommand(script, subcommand, scope) {
|
|
47
|
+
// Ternaries, not `||`: the command carries no pipe or ampersand at all.
|
|
48
|
+
const home = "(e.USERPROFILE?e.USERPROFILE:e.HOME?e.HOME:'.')";
|
|
49
|
+
const pick = scope === 'user'
|
|
50
|
+
? `f=p.join(${home},a[0]);`
|
|
51
|
+
: "f=p.join(e.CLAUDE_PROJECT_DIR?e.CLAUDE_PROJECT_DIR:'.',a[0]);" +
|
|
52
|
+
`if(!fs.existsSync(f))f=p.join(${home},a[0]);`;
|
|
53
|
+
const js = `var a='${[script, subcommand].filter(Boolean).join(' ')}'.split(' '),e=process.env,fs=require('fs'),p=require('path'),f;` + pick +
|
|
54
|
+
"var r=require('child_process').spawnSync(process.execPath,[f].concat(a.slice(1)),{stdio:'inherit'});" +
|
|
55
|
+
'process.exit(r.status===null?1:r.status)';
|
|
56
|
+
return `node -e "${js}"`;
|
|
57
|
+
}
|
|
33
58
|
/** Command that runs `node <script> <subcommand>` for a hook. */
|
|
34
59
|
export function helperHookCommand(script, subcommand, scope, windows = IS_WINDOWS) {
|
|
35
|
-
const winScript = script.replace(/\//g, '\\');
|
|
36
60
|
const arg = subcommand ? ` ${subcommand}` : '';
|
|
37
|
-
if (
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
if (windows) {
|
|
43
|
-
// cmd.exe equivalent of the sh probe below. `IF EXIST` checks the
|
|
44
|
-
// project-local path; falls back to %USERPROFILE% if missing.
|
|
45
|
-
return `cmd /c "IF EXIST \"%CLAUDE_PROJECT_DIR%\\${winScript}\" (node \"%CLAUDE_PROJECT_DIR%\\${winScript}\"${arg}) ELSE (node \"%USERPROFILE%\\${winScript}\"${arg})"`;
|
|
46
|
-
}
|
|
61
|
+
if (windows)
|
|
62
|
+
return windowsHookCommand(script, subcommand, scope);
|
|
63
|
+
if (scope === 'user')
|
|
64
|
+
return `sh -c 'exec node "\${HOME}/${script}"${arg}'`;
|
|
47
65
|
// POSIX sh: prefer project-local helpers, fall back to $HOME/.claude/.
|
|
48
66
|
// eslint-disable-next-line no-template-curly-in-string
|
|
49
67
|
const projVar = '${CLAUDE_PROJECT_DIR:-.}';
|
|
@@ -102,6 +120,17 @@ const IF_EXIST_PREAMBLE = /"IF EXIST "%CLAUDE_PROJECT_DIR%\\\.claude\\helpers\\[
|
|
|
102
120
|
* commands (other scripts, redirects, chains) are left alone.
|
|
103
121
|
*/
|
|
104
122
|
export function pinToUserHelpers(command, windows = IS_WINDOWS) {
|
|
123
|
+
// The #4045 Windows form: recognised only when it is exactly what ruflo generates.
|
|
124
|
+
const generated = /^node -e "var a='\.claude\/helpers\/([\w.-]+)((?: [\w:.=-]+)*)'\.split\(' '\)/.exec(command);
|
|
125
|
+
if (generated) {
|
|
126
|
+
if (!RUFLO_HELPERS.includes(generated[1]))
|
|
127
|
+
return null;
|
|
128
|
+
const script = `.claude/helpers/${generated[1]}`;
|
|
129
|
+
const subcommand = generated[2].trim();
|
|
130
|
+
return command === helperHookCommand(script, subcommand, 'project', true)
|
|
131
|
+
? helperHookCommand(script, subcommand, 'user', true)
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
105
134
|
const refs = [...command.matchAll(HELPER_REF)];
|
|
106
135
|
if (refs.length === 0)
|
|
107
136
|
return null;
|
|
@@ -44,7 +44,6 @@ const DOWNLOADER_WORDS = ['curl', 'wget'];
|
|
|
44
44
|
const STANDALONE_RISK_PATTERNS = [
|
|
45
45
|
/\bInvoke-Expression\b/i,
|
|
46
46
|
/\biex\s*\(/i,
|
|
47
|
-
/powershell(\.exe)?\s+.*-e(nc(odedcommand)?)?\s+\S/i,
|
|
48
47
|
/\/dev\/tcp\//i,
|
|
49
48
|
/\bnc\s+-e\b/i,
|
|
50
49
|
/base64\s+(-d|--decode)\b/i,
|
|
@@ -54,17 +53,37 @@ const STANDALONE_RISK_PATTERNS = [
|
|
|
54
53
|
// own the way `rm`/`chmod` are — pre-approving them removes the human
|
|
55
54
|
// review step for exactly the commands download-and-execute attacks use.
|
|
56
55
|
const RISKY_PREAPPROVE_WORDS = [...DANGEROUS_COMMAND_WORDS, ...SHELL_EXEC_WORDS, ...DOWNLOADER_WORDS];
|
|
56
|
+
// Encoded PowerShell (`powershell[.exe] ... -e|-enc|-encodedcommand <payload>`),
|
|
57
|
+
// matched on whitespace-separated tokens in ONE linear pass. The earlier regex
|
|
58
|
+
// (`powershell\\s+(?:\\S+\\s+)*?-e...`) is quadratic on repeated "powershell "
|
|
59
|
+
// tokens (every start rescans the tail: 256KB measured ~2.5s, 1MB ~40s) because
|
|
60
|
+
// the lazy token loop re-runs from each start. Same semantics: a token ending in
|
|
61
|
+
// powershell/powershell.exe, then LATER a token that is exactly -e/-enc/
|
|
62
|
+
// -encodedcommand, then at least one more token.
|
|
63
|
+
function hasEncodedPowershell(command) {
|
|
64
|
+
let seenPs = false;
|
|
65
|
+
let pendingEnc = false;
|
|
66
|
+
for (const raw of command.split(/\s+/)) {
|
|
67
|
+
if (!raw)
|
|
68
|
+
continue;
|
|
69
|
+
if (pendingEnc)
|
|
70
|
+
return true;
|
|
71
|
+
const t = raw.toLowerCase();
|
|
72
|
+
if (seenPs && (t === '-e' || t === '-enc' || t === '-encodedcommand'))
|
|
73
|
+
pendingEnc = true;
|
|
74
|
+
else if (t.endsWith('powershell') || t.endsWith('powershell.exe'))
|
|
75
|
+
seenPs = true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
57
79
|
function wordBoundaryMatches(text, word) {
|
|
58
80
|
return new RegExp(`\\b${word}\\b`, 'i').test(text);
|
|
59
81
|
}
|
|
60
82
|
/** Reasons a single hook `command` string looks risky (empty = clean). */
|
|
61
83
|
export function scanCommandStringForRisk(command) {
|
|
62
84
|
const reasons = [];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
reasons.push('matches a known-dangerous execution pattern (encoded/remote command execution)');
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
85
|
+
if (hasEncodedPowershell(command) || STANDALONE_RISK_PATTERNS.some((pattern) => pattern.test(command))) {
|
|
86
|
+
reasons.push('matches a known-dangerous execution pattern (encoded/remote command execution)');
|
|
68
87
|
}
|
|
69
88
|
const hasDownloader = DOWNLOADER_WORDS.some((w) => wordBoundaryMatches(command, w));
|
|
70
89
|
const hasShellExec = SHELL_EXEC_WORDS.some((w) => wordBoundaryMatches(command, w));
|
|
@@ -3305,19 +3305,23 @@ export const hooksTrajectoryEnd = {
|
|
|
3305
3305
|
}
|
|
3306
3306
|
catch { /* intelligence module not loadable — keep sona-only behaviour */ }
|
|
3307
3307
|
}
|
|
3308
|
-
// #2351: when an agent calls trajectory-end with
|
|
3309
|
-
//
|
|
3310
|
-
//
|
|
3311
|
-
//
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
//
|
|
3308
|
+
// #2351: when an agent calls trajectory-end with a non-empty `feedback`
|
|
3309
|
+
// string, the feedback was previously dropped on the floor —
|
|
3310
|
+
// `patternsExtracted` reported 0 and `pattern-search` never surfaced it.
|
|
3311
|
+
// Route it through the same store + embed path that pattern-store uses
|
|
3312
|
+
// so it becomes searchable. Best-effort: failures here must not turn the
|
|
3313
|
+
// trajectory-end call itself into a failure — the trajectory record was
|
|
3314
|
+
// already persisted above.
|
|
3315
|
+
//
|
|
3316
|
+
// #3662: #2351's original fix gated this on `!hasSteps` (step-less
|
|
3317
|
+
// trajectories only). The common start -> step -> end path (the one real
|
|
3318
|
+
// agents actually use) therefore recorded SONA state while leaving the
|
|
3319
|
+
// feedback unindexed. Non-empty feedback is promoted regardless of
|
|
3320
|
+
// whether steps were recorded — a trajectory's steps and its distilled
|
|
3321
|
+
// feedback are not mutually exclusive evidence.
|
|
3317
3322
|
let feedbackDistilled = { stored: false };
|
|
3318
|
-
const hasSteps = !!trajectory && trajectory.steps.length > 0;
|
|
3319
3323
|
const trimmedFeedback = typeof feedback === 'string' ? feedback.trim() : '';
|
|
3320
|
-
if (trajectory &&
|
|
3324
|
+
if (trajectory && trimmedFeedback.length > 0) {
|
|
3321
3325
|
const distilledPatternId = `pattern-feedback-${trajectoryId}-${Date.now()}`;
|
|
3322
3326
|
const patternMetadata = {
|
|
3323
3327
|
sourceTrajectoryId: trajectoryId,
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import * as path from 'path';
|
|
33
33
|
import * as fs from 'fs';
|
|
34
34
|
import * as crypto from 'crypto';
|
|
35
|
+
import { execFileSync } from 'child_process';
|
|
35
36
|
import { getMemoryRoot } from './memory-initializer.js';
|
|
36
37
|
import { encodeEmbedding } from './embedding-quantization.js';
|
|
37
38
|
import { loadBetterSqlite3 } from './shared-sqlite.js';
|
|
@@ -54,20 +55,41 @@ import { isEncryptedBlob } from '../encryption/vault.js';
|
|
|
54
55
|
// skip the native open (it would only throw) and warn once per distinct
|
|
55
56
|
// dbPath (not once per process — a process can legitimately touch more
|
|
56
57
|
// than one encrypted store via a custom `dbPath` argument).
|
|
58
|
+
/** Child-process body: print the first `argv[2]` bytes of `argv[1]` as hex. */
|
|
59
|
+
const HEADER_READER = "const fs=require('fs');const fd=fs.openSync(process.argv[1],'r');const b=Buffer.alloc(Number(process.argv[2]));" +
|
|
60
|
+
"const n=fs.readSync(fd,b,0,b.length,0);process.stdout.write(b.subarray(0,n).toString('hex'))";
|
|
61
|
+
const HEADER_READ_TIMEOUT_MS = 5000;
|
|
57
62
|
/** Read just the first `len` bytes of a file without loading the whole file
|
|
58
|
-
* into memory.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
63
|
+
* into memory.
|
|
64
|
+
*
|
|
65
|
+
* #4040: on POSIX this never opens the file in THIS process. close(2) on any
|
|
66
|
+
* descriptor for an inode drops every advisory lock the process holds on it
|
|
67
|
+
* (https://www.sqlite.org/howtocorrupt.html), including the ones SQLite's own
|
|
68
|
+
* live handle holds - a second process then believes it is the only
|
|
69
|
+
* connection and checkpoints/deletes the WAL under us. This module (and the
|
|
70
|
+
* shared AgentDB handle) can own such a handle, so the header is read by a
|
|
71
|
+
* short-lived child: empty environment, one bounded read, 5 s timeout. Windows
|
|
72
|
+
* byte-range locks are per handle, not per process, so it reads in-process. */
|
|
61
73
|
function readHeaderBytes(filePath, len) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
if (process.platform === 'win32') {
|
|
75
|
+
const fd = fs.openSync(filePath, 'r');
|
|
76
|
+
try {
|
|
77
|
+
const buf = Buffer.alloc(len);
|
|
78
|
+
const bytesRead = fs.readSync(fd, buf, 0, len, 0);
|
|
79
|
+
return buf.subarray(0, bytesRead);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
70
84
|
}
|
|
85
|
+
const hex = execFileSync(process.execPath, ['-e', HEADER_READER, filePath, String(len)], {
|
|
86
|
+
env: {},
|
|
87
|
+
timeout: HEADER_READ_TIMEOUT_MS,
|
|
88
|
+
maxBuffer: len * 2 + 64,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
});
|
|
92
|
+
return Buffer.from(hex, 'hex');
|
|
71
93
|
}
|
|
72
94
|
// RFE1 wire format is magic(4) + iv(12) + ciphertext(N) + tag(16); the
|
|
73
95
|
// shortest possible blob is 32 bytes. 64 gives headroom.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.56.
|
|
3
|
+
"version": "3.56.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -98,13 +98,13 @@
|
|
|
98
98
|
"vitest": "^4.1.0"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@claude-flow/cli-core": "3.7.
|
|
101
|
+
"@claude-flow/cli-core": "3.7.1",
|
|
102
102
|
"@claude-flow/codex": "3.0.3",
|
|
103
103
|
"@claude-flow/mcp": "3.1.1",
|
|
104
104
|
"@claude-flow/neural": "3.0.0",
|
|
105
105
|
"@claude-flow/plugin-agent-federation": "1.0.1",
|
|
106
106
|
"@claude-flow/security": "3.0.4",
|
|
107
|
-
"@claude-flow/shared": "3.0.
|
|
107
|
+
"@claude-flow/shared": "3.0.3",
|
|
108
108
|
"@iarna/toml": "^2.2.5",
|
|
109
109
|
"@noble/ed25519": "2.3.0",
|
|
110
110
|
"@ruvector/rabitq-wasm": "0.1.0",
|