@claude-flow/cli 3.64.0 → 3.65.1

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.
Files changed (93) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/catalog-manifest.json +2 -2
  3. package/dist/src/commands/cost.d.ts +18 -0
  4. package/dist/src/commands/cost.js +195 -0
  5. package/dist/src/commands/doctor-auto-approve.d.ts +9 -0
  6. package/dist/src/commands/doctor-auto-approve.js +32 -0
  7. package/dist/src/commands/doctor.js +21 -3
  8. package/dist/src/commands/hive-mind-worker.d.ts +2 -0
  9. package/dist/src/commands/hive-mind-worker.js +9 -3
  10. package/dist/src/commands/hive-mind.js +15 -1
  11. package/dist/src/commands/hooks.js +8 -2
  12. package/dist/src/commands/index.js +2 -0
  13. package/dist/src/commands/init.js +76 -2
  14. package/dist/src/commands/mods.js +13 -0
  15. package/dist/src/commands/plugins-paths.d.ts +13 -0
  16. package/dist/src/commands/plugins-paths.js +55 -0
  17. package/dist/src/commands/plugins-verify.d.ts +9 -0
  18. package/dist/src/commands/plugins-verify.js +92 -0
  19. package/dist/src/commands/plugins.js +13 -1
  20. package/dist/src/cost/attribution.d.ts +35 -0
  21. package/dist/src/cost/attribution.js +101 -0
  22. package/dist/src/cost/budget-file.d.ts +57 -0
  23. package/dist/src/cost/budget-file.js +113 -0
  24. package/dist/src/cost/litellm.d.ts +69 -0
  25. package/dist/src/cost/litellm.js +135 -0
  26. package/dist/src/init/executor.d.ts +7 -2
  27. package/dist/src/init/executor.js +64 -10
  28. package/dist/src/init/helper-commands.d.ts +16 -0
  29. package/dist/src/init/helper-commands.js +73 -0
  30. package/dist/src/init/risky-settings-gate.d.ts +39 -0
  31. package/dist/src/init/risky-settings-gate.js +130 -0
  32. package/dist/src/init/settings-risk-scanner.d.ts +35 -0
  33. package/dist/src/init/settings-risk-scanner.js +85 -0
  34. package/dist/src/init/types.d.ts +12 -0
  35. package/dist/src/mcp-tools/capability-brain.d.ts +1 -1
  36. package/dist/src/mcp-tools/capability-brain.js +1 -1
  37. package/dist/src/mcp-tools/hooks-tools.js +2 -1
  38. package/dist/src/mcp-tools/http-fetch-pinned.d.ts +44 -0
  39. package/dist/src/mcp-tools/http-fetch-pinned.js +148 -0
  40. package/dist/src/mcp-tools/http-fetch-tools.d.ts +11 -2
  41. package/dist/src/mcp-tools/http-fetch-tools.js +19 -70
  42. package/dist/src/memory/verified-guards.d.ts +70 -0
  43. package/dist/src/memory/verified-guards.js +101 -0
  44. package/dist/src/memory/verified-outcomes.d.ts +68 -3
  45. package/dist/src/memory/verified-outcomes.js +88 -19
  46. package/dist/src/memory/verified-reverify.d.ts +63 -0
  47. package/dist/src/memory/verified-reverify.js +174 -0
  48. package/dist/src/missions/schemas.d.ts +4 -4
  49. package/dist/src/mods/apply.d.ts +2 -0
  50. package/dist/src/mods/apply.js +68 -8
  51. package/dist/src/mods/command-registry/schema.d.ts +36 -36
  52. package/dist/src/mods/compact-snapshots.d.ts +21 -0
  53. package/dist/src/mods/compact-snapshots.js +45 -0
  54. package/dist/src/mods/headless-check.d.ts +58 -0
  55. package/dist/src/mods/headless-check.js +137 -0
  56. package/dist/src/mods/headless-plugins.d.ts +44 -0
  57. package/dist/src/mods/headless-plugins.js +92 -0
  58. package/dist/src/mods/install-trust.d.ts +28 -0
  59. package/dist/src/mods/install-trust.js +127 -0
  60. package/dist/src/mods/install.d.ts +19 -0
  61. package/dist/src/mods/install.js +38 -1
  62. package/dist/src/mods/plugin-paths.d.ts +60 -0
  63. package/dist/src/mods/plugin-paths.js +131 -0
  64. package/dist/src/mods/plugin-repair.d.ts +8 -0
  65. package/dist/src/mods/plugin-repair.js +7 -0
  66. package/dist/src/plugins/marketplace-trust/scan.d.ts +23 -0
  67. package/dist/src/plugins/marketplace-trust/scan.js +255 -0
  68. package/dist/src/plugins/marketplace-trust/signature.d.ts +53 -0
  69. package/dist/src/plugins/marketplace-trust/signature.js +140 -0
  70. package/dist/src/plugins/marketplace-trust/verify-status.d.ts +42 -0
  71. package/dist/src/plugins/marketplace-trust/verify-status.js +110 -0
  72. package/dist/src/services/headless-worker-executor.d.ts +2 -0
  73. package/dist/src/services/headless-worker-executor.js +13 -3
  74. package/package.json +2 -1
  75. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +8 -1
  76. package/plugins/ruflo-metaharness/CHANGELOG.md +6 -0
  77. package/plugins/ruflo-metaharness/README.md +14 -1
  78. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +2 -0
  79. package/plugins/ruflo-metaharness/hooks/hooks.json +16 -2
  80. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +12 -2
  81. package/plugins/ruflo-metaharness/scripts/_secret-env.cjs +50 -0
  82. package/plugins/ruflo-metaharness/scripts/_secret.mjs +35 -0
  83. package/plugins/ruflo-metaharness/scripts/smoke.sh +7 -7
  84. package/plugins/ruflo-metaharness/skills/harness-bench/SKILL.md +2 -1
  85. package/plugins/ruflo-metaharness/skills/harness-drift-from-history/SKILL.md +1 -1
  86. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +2 -1
  87. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  88. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +2 -1
  89. package/plugins/ruflo-metaharness/skills/harness-mint/SKILL.md +2 -1
  90. package/plugins/ruflo-metaharness/skills/harness-oia-audit/SKILL.md +1 -0
  91. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  92. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +2 -1
  93. package/plugins/ruflo-metaharness/skills/harness-similarity/SKILL.md +1 -1
@@ -0,0 +1,130 @@
1
+ /**
2
+ * ADR-509 §2.3: the stop `ruflo init` / `ruflo init upgrade --settings` makes
3
+ * before carrying a project settings file's auto-approve keys forward.
4
+ *
5
+ * The check runs BEFORE anything is written, so a stop leaves the project as
6
+ * it was. `keep` proceeds (the keys are still reported); `drop` proceeds and
7
+ * the merged file is written without them.
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { isRufloHelperCommand } from './helper-commands.js';
12
+ import { CRITICAL_HELPERS } from './helper-refresh.js';
13
+ import { HELPERS_MANIFEST_FILE, sha256Hex, verifyHelpersManifest } from './helper-signing.js';
14
+ import { dropAutoApproveKeys, formatRiskFindingsAsWarnings, scanProjectAutoApproveKeys, } from './settings-risk-scanner.js';
15
+ export function scanAutoApprove(settings) {
16
+ return scanProjectAutoApproveKeys(settings, isRufloHelperCommand);
17
+ }
18
+ /** Reads a settings file and judges it; null when the file is absent, unreadable or not a JSON object. */
19
+ export function checkRiskySettings(settingsPath, mode = 'stop') {
20
+ if (!fs.existsSync(settingsPath))
21
+ return null;
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
30
+ return null;
31
+ const findings = scanAutoApprove(parsed);
32
+ if (findings.length === 0)
33
+ return null;
34
+ return { mode, findings, lines: formatRiskFindingsAsWarnings(findings), stop: mode === 'stop' };
35
+ }
36
+ /**
37
+ * ADR-509 §2.3: the helpers ruflo's hooks launch, as the PROJECT ships them.
38
+ * `init` keeps an existing `.claude/helpers/<name>` unless --force, and the
39
+ * project-scope hook commands prefer the project's copy, so a repository that
40
+ * ships its own hook-handler.cjs runs it under an exact ruflo hook command. A
41
+ * helper is trusted when its bytes are the copy this ruflo ships, or its
42
+ * SHA-256 is in a helpers manifest signed with ruflo's key (this ruflo's, or
43
+ * the project's own, which an older signed release left). Anything else is a
44
+ * finding. A `.LOCKED` helpers dir (deliberate local edits) is skipped, and with
45
+ * no ground truth at all this fails open. `pubkeyPem` is injectable for tests.
46
+ */
47
+ export function untrustedProjectHelpers(targetDir, sourceHelpersDir, pubkeyPem) {
48
+ const helpersDir = path.join(targetDir, '.claude', 'helpers');
49
+ // .LOCKED is the sanctioned marker for deliberately edited helpers (helper-refresh honours it too).
50
+ if (fs.existsSync(path.join(helpersDir, '.LOCKED')))
51
+ return [];
52
+ const signed = (dir) => {
53
+ if (!dir)
54
+ return null;
55
+ try {
56
+ return verifyHelpersManifest(fs.readFileSync(path.join(dir, HELPERS_MANIFEST_FILE), 'utf-8'), pubkeyPem);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ };
62
+ const manifests = [signed(sourceHelpersDir), signed(helpersDir)].filter((m) => m !== null);
63
+ // No ground truth at all (no shipped copy, no signed manifest): fail open, as
64
+ // helper-integrity does, rather than flag every install.
65
+ if (!sourceHelpersDir && manifests.length === 0)
66
+ return [];
67
+ const out = [];
68
+ for (const name of CRITICAL_HELPERS) {
69
+ const file = path.join(helpersDir, name);
70
+ let bytes;
71
+ try {
72
+ if (!fs.lstatSync(file).isFile())
73
+ throw new Error('not a regular file');
74
+ bytes = fs.readFileSync(file);
75
+ }
76
+ catch (error) {
77
+ if (error.code === 'ENOENT')
78
+ continue;
79
+ out.push({ target: { kind: 'helper', name }, location: `.claude/helpers/${name}`, snippet: '(unreadable or not a regular file)', reason: "is not ruflo's signed helper; ruflo's hooks would run it" });
80
+ continue;
81
+ }
82
+ if (sourceHelpersDir) {
83
+ try {
84
+ if (bytes.equals(fs.readFileSync(path.join(sourceHelpersDir, name))))
85
+ continue;
86
+ }
87
+ catch {
88
+ // not shipped by this ruflo: fall through to the signed manifests
89
+ }
90
+ }
91
+ const hash = sha256Hex(bytes);
92
+ if (manifests.some((m) => m.files[name] === hash))
93
+ continue;
94
+ out.push({
95
+ target: { kind: 'helper', name },
96
+ location: `.claude/helpers/${name}`,
97
+ snippet: `sha256 ${hash.slice(0, 16)}…`,
98
+ reason: "differs from ruflo's signed helper; init keeps it and ruflo's hooks would run it",
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * The init gate: the settings file's auto-approve keys plus the project's
105
+ * untrusted helpers. Null when there is nothing to report.
106
+ */
107
+ export function checkInitTrust(targetDir, mode, sourceHelpersDir) {
108
+ const settings = checkRiskySettings(path.join(targetDir, '.claude', 'settings.json'), mode);
109
+ const helpers = untrustedProjectHelpers(targetDir, sourceHelpersDir);
110
+ const findings = [...(settings?.findings ?? []), ...helpers];
111
+ if (findings.length === 0)
112
+ return null;
113
+ return { mode, findings, lines: formatRiskFindingsAsWarnings(findings), stop: mode === 'stop' };
114
+ }
115
+ /** `drop`: the settings without the auto-approve keys; otherwise the settings unchanged. */
116
+ export function applyRiskyMode(settings, mode) {
117
+ if (mode !== 'drop')
118
+ return { settings, dropped: 0 };
119
+ const findings = scanAutoApprove(settings);
120
+ return findings.length ? { settings: dropAutoApproveKeys(settings, findings), dropped: findings.length } : { settings, dropped: 0 };
121
+ }
122
+ /** Reads `--keep-risky-settings` / `--drop-risky-settings` (camel or kebab); both at once is an error. */
123
+ export function riskyModeFromFlags(flags) {
124
+ const keep = flags.keepRiskySettings === true || flags['keep-risky-settings'] === true;
125
+ const drop = flags.dropRiskySettings === true || flags['drop-risky-settings'] === true;
126
+ if (keep && drop)
127
+ return { error: '--keep-risky-settings and --drop-risky-settings cannot be used together' };
128
+ return keep ? 'keep' : drop ? 'drop' : 'stop';
129
+ }
130
+ //# sourceMappingURL=risky-settings-gate.js.map
@@ -44,4 +44,39 @@ export declare function scanAllowRuleForRisk(rule: string): string[];
44
44
  export declare function scanSettingsForRisk(settings: Record<string, unknown>): SettingsRiskFinding[];
45
45
  /** Formats findings as short, human-readable CLI warning lines. */
46
46
  export declare function formatRiskFindingsAsWarnings(findings: SettingsRiskFinding[]): string[];
47
+ /** What a finding points at, precisely enough for dropAutoApproveKeys to remove only that. */
48
+ export type AutoApproveTarget = {
49
+ kind: 'enableAllProjectMcpServers';
50
+ } | {
51
+ kind: 'enabledMcpjsonServers';
52
+ } | {
53
+ kind: 'defaultMode';
54
+ } | {
55
+ kind: 'allow';
56
+ index: number;
57
+ } | {
58
+ kind: 'hook';
59
+ event: string;
60
+ group: number;
61
+ hook: number;
62
+ }
63
+ /** A project `.claude/helpers/<name>` that is not ruflo's signed copy (init keeps it without --force). */
64
+ | {
65
+ kind: 'helper';
66
+ name: string;
67
+ };
68
+ export interface AutoApproveFinding extends SettingsRiskFinding {
69
+ target: AutoApproveTarget;
70
+ }
71
+ /**
72
+ * The auto-approve keys of a PROJECT settings file (`.claude/settings.json` or
73
+ * `.claude/settings.local.json`). `isRufloHook` decides which hook commands are
74
+ * ruflo's own helpers (injected so this module stays dependency-free).
75
+ */
76
+ export declare function scanProjectAutoApproveKeys(settings: Record<string, unknown>, isRufloHook: (command: string) => boolean): AutoApproveFinding[];
77
+ /** A deep copy of `settings` without the keys/entries the findings point at. Never mutates its input. */
78
+ export declare function dropAutoApproveKeys(settings: Record<string, unknown>, findings: readonly AutoApproveFinding[]): Record<string, unknown>;
79
+ /** What `init` does when the existing settings carry auto-approve keys. */
80
+ export type RiskySettingsMode = 'stop' | 'keep' | 'drop';
81
+ export declare const RISKY_SETTINGS_HINT = "Re-run with --drop-risky-settings to write the merged settings without these keys (and replace any flagged helper with ruflo's copy), or --keep-risky-settings to keep them.";
47
82
  //# sourceMappingURL=settings-risk-scanner.d.ts.map
@@ -177,4 +177,89 @@ export function scanSettingsForRisk(settings) {
177
177
  export function formatRiskFindingsAsWarnings(findings) {
178
178
  return findings.map((f) => `${f.location}: ${f.reason} — "${f.snippet}"`);
179
179
  }
180
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
181
+ /** A bare `Bash`, `Bash(*)`, `Bash(*:*)` or a `*` wildcard pre-approves every command. */
182
+ function isBlanketAllow(rule) {
183
+ const t = rule.trim();
184
+ return t === '*' || /^Bash$/i.test(t) || /^Bash\(\s*\*\s*(:\s*\*\s*)?\)$/i.test(t) || /^\*\s*\(.*\)$/.test(t);
185
+ }
186
+ /**
187
+ * The auto-approve keys of a PROJECT settings file (`.claude/settings.json` or
188
+ * `.claude/settings.local.json`). `isRufloHook` decides which hook commands are
189
+ * ruflo's own helpers (injected so this module stays dependency-free).
190
+ */
191
+ export function scanProjectAutoApproveKeys(settings, isRufloHook) {
192
+ const out = [];
193
+ if (settings.enableAllProjectMcpServers === true) {
194
+ out.push({ target: { kind: 'enableAllProjectMcpServers' }, location: 'enableAllProjectMcpServers', snippet: 'true', reason: 'starts every .mcp.json server in the project without asking' });
195
+ }
196
+ const enabled = settings.enabledMcpjsonServers;
197
+ if (Array.isArray(enabled) && enabled.length > 0) {
198
+ out.push({ target: { kind: 'enabledMcpjsonServers' }, location: 'enabledMcpjsonServers', snippet: buildSnippet(enabled.map(String).join(', ')), reason: 'pre-approves .mcp.json servers before the trust dialog' });
199
+ }
200
+ const permissions = isRecord(settings.permissions) ? settings.permissions : undefined;
201
+ if (permissions?.defaultMode === 'bypassPermissions') {
202
+ out.push({ target: { kind: 'defaultMode' }, location: 'permissions.defaultMode', snippet: 'bypassPermissions', reason: 'turns off every permission prompt' });
203
+ }
204
+ if (Array.isArray(permissions?.allow)) {
205
+ permissions.allow.forEach((rule, index) => {
206
+ if (typeof rule === 'string' && isBlanketAllow(rule)) {
207
+ out.push({ target: { kind: 'allow', index }, location: `permissions.allow[${index}]`, snippet: buildSnippet(rule), reason: 'pre-approves every command (bare Bash or * wildcard)' });
208
+ }
209
+ });
210
+ }
211
+ const hooks = isRecord(settings.hooks) ? settings.hooks : undefined;
212
+ for (const [event, groups] of Object.entries(hooks ?? {})) {
213
+ if (!Array.isArray(groups))
214
+ continue;
215
+ groups.forEach((g, group) => {
216
+ const list = isRecord(g) && Array.isArray(g.hooks) ? g.hooks : [];
217
+ list.forEach((h, hook) => {
218
+ if (!isRecord(h) || typeof h.command !== 'string' || isRufloHook(h.command))
219
+ return;
220
+ out.push({ target: { kind: 'hook', event, group, hook }, location: `hooks.${event}[${group}].hooks[${hook}]`, snippet: buildSnippet(h.command), reason: 'runs a command that is not a ruflo helper' });
221
+ });
222
+ });
223
+ }
224
+ return out;
225
+ }
226
+ /** A deep copy of `settings` without the keys/entries the findings point at. Never mutates its input. */
227
+ export function dropAutoApproveKeys(settings, findings) {
228
+ const next = JSON.parse(JSON.stringify(settings));
229
+ const perms = isRecord(next.permissions) ? next.permissions : undefined;
230
+ const allowDrop = new Set();
231
+ const hookDrop = new Set();
232
+ for (const { target: t } of findings) {
233
+ if (t.kind === 'enableAllProjectMcpServers')
234
+ delete next.enableAllProjectMcpServers;
235
+ else if (t.kind === 'enabledMcpjsonServers')
236
+ delete next.enabledMcpjsonServers;
237
+ else if (t.kind === 'defaultMode' && perms)
238
+ delete perms.defaultMode;
239
+ else if (t.kind === 'allow')
240
+ allowDrop.add(t.index);
241
+ else if (t.kind === 'hook')
242
+ hookDrop.add(`${t.event}\u0000${t.group}\u0000${t.hook}`);
243
+ }
244
+ if (perms && Array.isArray(perms.allow))
245
+ perms.allow = perms.allow.filter((_, i) => !allowDrop.has(i));
246
+ if (isRecord(next.hooks) && hookDrop.size) {
247
+ for (const [event, groups] of Object.entries(next.hooks)) {
248
+ if (!Array.isArray(groups))
249
+ continue;
250
+ const kept = groups
251
+ .map((g, gi) => (isRecord(g) && Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((_, hi) => !hookDrop.has(`${event}\u0000${gi}\u0000${hi}`)) } : g))
252
+ .filter((g) => !(isRecord(g) && Array.isArray(g.hooks) && g.hooks.length === 0));
253
+ if (kept.length)
254
+ next.hooks[event] = kept;
255
+ else
256
+ delete next.hooks[event];
257
+ }
258
+ // No hooks left: drop the key, so init writes its own hooks as it does for a file without any.
259
+ if (Object.keys(next.hooks).length === 0)
260
+ delete next.hooks;
261
+ }
262
+ return next;
263
+ }
264
+ export const RISKY_SETTINGS_HINT = 'Re-run with --drop-risky-settings to write the merged settings without these keys (and replace any flagged helper with ruflo\'s copy), or --keep-risky-settings to keep them.';
180
265
  //# sourceMappingURL=settings-risk-scanner.js.map
@@ -296,6 +296,14 @@ export interface InitOptions {
296
296
  * GitHub contributor graph. Pass `--attribution` to opt in.
297
297
  */
298
298
  attribution?: boolean;
299
+ /**
300
+ * ADR-509 §2.3 — what to do when the existing project settings.json carries
301
+ * auto-approve keys (enableAllProjectMcpServers, enabledMcpjsonServers,
302
+ * bypassPermissions, blanket Bash allows, non-ruflo hooks): `stop`
303
+ * (default — nothing is written), `keep` (--keep-risky-settings) or `drop`
304
+ * (--drop-risky-settings).
305
+ */
306
+ riskySettings?: 'stop' | 'keep' | 'drop';
299
307
  }
300
308
  /**
301
309
  * Default init options - full V3 setup
@@ -334,5 +342,9 @@ export interface InitResult {
334
342
  * init — surfaced so a user can review before trusting it.
335
343
  */
336
344
  warnings?: string[];
345
+ /** ADR-509 §2.3 — auto-approve keys found in the existing project settings.json. */
346
+ riskySettings?: string[];
347
+ /** ADR-509 §2.3 — init stopped before writing anything because of riskySettings. */
348
+ stoppedOnRiskySettings?: boolean;
337
349
  }
338
350
  //# sourceMappingURL=types.d.ts.map
@@ -97,7 +97,7 @@ export interface CapabilityBrain {
97
97
  };
98
98
  }
99
99
  /** Top-level commands from the CLI command loader. Kept as a checked manifest. */
100
- export declare const RUFLO_CLI_COMMANDS: readonly ["init", "start", "status", "task", "session", "agent", "swarm", "memory", "mcp", "config", "migrate", "hooks", "workflow", "hive-mind", "process", "daemon", "version", "neural", "security", "performance", "providers", "plugins", "deployment", "claims", "policy", "embeddings", "completions", "doctor", "verify", "analyze", "route", "progress", "issues", "update", "ruvector", "benchmark", "guidance", "appliance", "appliance-advanced", "transfer-store", "cleanup", "autopilot", "gaia-bench", "metaharness", "eject", "funnel", "settings", "auth", "proxy", "advisor", "spinner", "announcements", "tui", "harness"];
100
+ export declare const RUFLO_CLI_COMMANDS: readonly ["init", "start", "status", "task", "session", "agent", "swarm", "memory", "mcp", "config", "migrate", "hooks", "workflow", "hive-mind", "process", "daemon", "version", "neural", "security", "performance", "providers", "plugins", "deployment", "claims", "policy", "embeddings", "completions", "doctor", "verify", "analyze", "route", "progress", "issues", "update", "ruvector", "benchmark", "guidance", "appliance", "appliance-advanced", "transfer-store", "cleanup", "autopilot", "gaia-bench", "metaharness", "eject", "funnel", "settings", "auth", "proxy", "advisor", "spinner", "cost", "announcements", "tui", "harness"];
101
101
  export interface ImplementationLoopStep {
102
102
  id: string;
103
103
  name: string;
@@ -15,7 +15,7 @@ export const RUFLO_CLI_COMMANDS = [
15
15
  'doctor', 'verify', 'analyze', 'route', 'progress', 'issues', 'update',
16
16
  'ruvector', 'benchmark', 'guidance', 'appliance', 'appliance-advanced',
17
17
  'transfer-store', 'cleanup', 'autopilot', 'gaia-bench', 'metaharness',
18
- 'eject', 'funnel', 'settings', 'auth', 'proxy', 'advisor', 'spinner',
18
+ 'eject', 'funnel', 'settings', 'auth', 'proxy', 'advisor', 'spinner', 'cost',
19
19
  'announcements', 'tui', 'harness',
20
20
  ];
21
21
  export const IMPLEMENTATION_LOOP = [
@@ -5482,7 +5482,8 @@ export const hooksVerifiedIngest = {
5482
5482
  handler: async (params) => {
5483
5483
  const { ingestVerifiedOutcomes } = await import('../memory/verified-outcomes.js');
5484
5484
  const sona = await getSONAOptimizer();
5485
- const result = await ingestVerifiedOutcomes({ dryRun: params.dryRun === true, force: params.force === true || params.dryRun === true, sona: sona });
5485
+ // ADR-503 §2.3: only this explicit ingest may re-verify (run project code), and only when learningLoop.reverify is on.
5486
+ const result = await ingestVerifiedOutcomes({ dryRun: params.dryRun === true, force: params.force === true || params.dryRun === true, sona: sona, allowReverify: true });
5486
5487
  return { success: true, ...result };
5487
5488
  },
5488
5489
  };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * http_fetch's address checks and connection-pinned transport (ADR-164 §5.1.8).
3
+ *
4
+ * Checking the URL's hostname string is not enough: a public-looking name can resolve to
5
+ * loopback, RFC-1918 or cloud metadata (169.254.169.254), and `fetch()` resolves the name
6
+ * again on its own, so even a pre-resolution check can be rebound (TTL-0 DNS) between the
7
+ * check and the connect. Here the socket's DNS lookup *is* the check: `pinnedLookup`
8
+ * resolves the name, refuses it when any address is private, and hands the socket only the
9
+ * addresses it checked, so the connection cannot land anywhere else. IP-literal hosts never
10
+ * reach a lookup; `validateUrl` checks those by text, with the same `isPrivateOrLoopback`.
11
+ *
12
+ * @module @claude-flow/cli/mcp-tools/http-fetch-pinned
13
+ */
14
+ import { type LookupFunction } from 'node:net';
15
+ /** Raised when a hostname resolves to an address the allowlist refuses. */
16
+ export declare class PrivateAddressError extends Error {
17
+ readonly code = "PRIVATE_ADDRESS";
18
+ constructor(host: string, address: string);
19
+ }
20
+ export declare const allowPrivateAddresses: () => boolean;
21
+ /**
22
+ * Whether a host (an IP literal, bracketed or not, or `localhost`) is one http_fetch refuses
23
+ * by default. A name that is not an IP literal is `false` here: what it resolves to is
24
+ * checked at connect time by `pinnedLookup`.
25
+ */
26
+ export declare function isPrivateOrLoopback(host: string): boolean;
27
+ /**
28
+ * The socket's DNS lookup: resolve every address, refuse the host when any of them is
29
+ * private (unless opted in), and return only those checked addresses to the socket.
30
+ */
31
+ export declare const pinnedLookup: LookupFunction;
32
+ export interface PinnedInit {
33
+ method: string;
34
+ headers: Record<string, string>;
35
+ body?: string;
36
+ signal: AbortSignal;
37
+ }
38
+ /**
39
+ * One HTTP(S) request whose connection goes only to an address `pinnedLookup` checked, with
40
+ * no redirect following and a fresh connection every time (no pooled socket from elsewhere).
41
+ * Returns a standard `Response` so the caller reads it as it would a `fetch()` result.
42
+ */
43
+ export declare function pinnedRequest(url: string, init: PinnedInit): Promise<Response>;
44
+ //# sourceMappingURL=http-fetch-pinned.d.ts.map
@@ -0,0 +1,148 @@
1
+ /**
2
+ * http_fetch's address checks and connection-pinned transport (ADR-164 §5.1.8).
3
+ *
4
+ * Checking the URL's hostname string is not enough: a public-looking name can resolve to
5
+ * loopback, RFC-1918 or cloud metadata (169.254.169.254), and `fetch()` resolves the name
6
+ * again on its own, so even a pre-resolution check can be rebound (TTL-0 DNS) between the
7
+ * check and the connect. Here the socket's DNS lookup *is* the check: `pinnedLookup`
8
+ * resolves the name, refuses it when any address is private, and hands the socket only the
9
+ * addresses it checked, so the connection cannot land anywhere else. IP-literal hosts never
10
+ * reach a lookup; `validateUrl` checks those by text, with the same `isPrivateOrLoopback`.
11
+ *
12
+ * @module @claude-flow/cli/mcp-tools/http-fetch-pinned
13
+ */
14
+ import { lookup as dnsLookup } from 'node:dns';
15
+ import * as http from 'node:http';
16
+ import * as https from 'node:https';
17
+ import { isIP } from 'node:net';
18
+ import { Readable } from 'node:stream';
19
+ /** Raised when a hostname resolves to an address the allowlist refuses. */
20
+ export class PrivateAddressError extends Error {
21
+ code = 'PRIVATE_ADDRESS';
22
+ constructor(host, address) {
23
+ super(`host ${host} resolves to ${address}, which is loopback/private/link-local; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 to override`);
24
+ this.name = 'PrivateAddressError';
25
+ }
26
+ }
27
+ export const allowPrivateAddresses = () => process.env.CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE === '1';
28
+ /** The 16-bit groups of an IPv6 address (brackets stripped), or null when it is not one. */
29
+ function ipv6Groups(addr) {
30
+ if (isIP(addr) !== 6)
31
+ return null;
32
+ let text = addr.split('%')[0];
33
+ // A trailing dotted IPv4 (`::ffff:1.2.3.4`) is two groups.
34
+ const v4 = text.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
35
+ if (v4) {
36
+ const [a, b, c, d] = v4.slice(1).map(Number);
37
+ text = `${text.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
38
+ }
39
+ const [head, tail] = text.includes('::') ? text.split('::') : [text, undefined];
40
+ const parse = (s) => (s ? s.split(':').map((g) => parseInt(g, 16)) : []);
41
+ const h = parse(head);
42
+ const t = tail === undefined ? [] : parse(tail);
43
+ const groups = tail === undefined ? h : [...h, ...new Array(8 - h.length - t.length).fill(0), ...t];
44
+ return groups.length === 8 && groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
45
+ }
46
+ function privateIPv4(a, b) {
47
+ return a === 0 // 0.0.0.0/8 ("this" network)
48
+ || a === 127 // loopback
49
+ || a === 10 // RFC 1918
50
+ || (a === 172 && b >= 16 && b <= 31) // RFC 1918
51
+ || (a === 192 && b === 168) // RFC 1918
52
+ || (a === 169 && b === 254) // link-local, cloud metadata
53
+ || (a === 100 && b >= 64 && b <= 127) // CGNAT
54
+ || (a === 198 && (b === 18 || b === 19)) // benchmarking
55
+ || a >= 224; // multicast, reserved, broadcast
56
+ }
57
+ /**
58
+ * Whether a host (an IP literal, bracketed or not, or `localhost`) is one http_fetch refuses
59
+ * by default. A name that is not an IP literal is `false` here: what it resolves to is
60
+ * checked at connect time by `pinnedLookup`.
61
+ */
62
+ export function isPrivateOrLoopback(host) {
63
+ const lower = host.toLowerCase();
64
+ if (lower === 'localhost' || lower === 'localhost.localdomain' || lower.endsWith('.localhost'))
65
+ return true;
66
+ const bare = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower;
67
+ const v4 = bare.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
68
+ if (v4)
69
+ return privateIPv4(Number(v4[1]), Number(v4[2]));
70
+ const g = ipv6Groups(bare);
71
+ if (!g)
72
+ return false;
73
+ const embedded = (hi) => privateIPv4(hi >> 8, hi & 0xff);
74
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] <= 1)
75
+ return true; // :: and ::1
76
+ if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff)
77
+ return embedded(g[6]); // ::ffff:a.b.c.d (IPv4-mapped)
78
+ if (g.slice(0, 6).every((x) => x === 0))
79
+ return true; // ::a.b.c.d (deprecated IPv4-compatible)
80
+ if (g[0] === 0x64 && g[1] === 0xff9b)
81
+ return true; // 64:ff9b::/96 and /48 NAT64: may wrap any IPv4
82
+ if (g[0] === 0x2002)
83
+ return embedded(g[1]); // 6to4 carries an IPv4
84
+ if ((g[0] & 0xfe00) === 0xfc00)
85
+ return true; // fc00::/7 unique local
86
+ if ((g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0)
87
+ return true; // fe80::/10 link-local, fec0::/10 site-local
88
+ if ((g[0] & 0xff00) === 0xff00)
89
+ return true; // ff00::/8 multicast
90
+ return false;
91
+ }
92
+ /**
93
+ * The socket's DNS lookup: resolve every address, refuse the host when any of them is
94
+ * private (unless opted in), and return only those checked addresses to the socket.
95
+ */
96
+ export const pinnedLookup = (hostname, options, callback) => {
97
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
98
+ if (err)
99
+ return callback(err, []);
100
+ const list = addresses;
101
+ if (!allowPrivateAddresses()) {
102
+ const bad = list.find((a) => isPrivateOrLoopback(a.address));
103
+ if (bad || list.length === 0) {
104
+ return callback(new PrivateAddressError(hostname, bad?.address ?? 'no address'), []);
105
+ }
106
+ }
107
+ if (options.all)
108
+ return callback(null, list);
109
+ callback(null, list[0].address, list[0].family);
110
+ });
111
+ };
112
+ /** Statuses whose Response must not carry a body. */
113
+ const NULL_BODY = new Set([101, 103, 204, 205, 304]);
114
+ /**
115
+ * One HTTP(S) request whose connection goes only to an address `pinnedLookup` checked, with
116
+ * no redirect following and a fresh connection every time (no pooled socket from elsewhere).
117
+ * Returns a standard `Response` so the caller reads it as it would a `fetch()` result.
118
+ */
119
+ export function pinnedRequest(url, init) {
120
+ const target = new URL(url);
121
+ const client = target.protocol === 'https:' ? https : http;
122
+ const headers = { accept: '*/*', ...init.headers };
123
+ if (init.body !== undefined && !Object.keys(headers).some((k) => k.toLowerCase() === 'content-length')) {
124
+ headers['content-length'] = String(Buffer.byteLength(init.body));
125
+ }
126
+ return new Promise((resolve, reject) => {
127
+ const req = client.request(target, { method: init.method, headers, lookup: pinnedLookup, agent: false, signal: init.signal }, (res) => {
128
+ const out = new Headers();
129
+ for (const [key, value] of Object.entries(res.headers)) {
130
+ for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value])
131
+ out.append(key, v);
132
+ }
133
+ const status = res.statusCode ?? 0;
134
+ if (status < 200 || status > 599) {
135
+ res.destroy();
136
+ reject(new Error(`unsupported HTTP status ${status}`));
137
+ return;
138
+ }
139
+ const body = NULL_BODY.has(status) || init.method === 'HEAD' ? null : Readable.toWeb(res);
140
+ if (body === null)
141
+ res.resume();
142
+ resolve(new Response(body, { status, statusText: res.statusMessage ?? '', headers: out }));
143
+ });
144
+ req.on('error', reject);
145
+ req.end(init.body);
146
+ });
147
+ }
148
+ //# sourceMappingURL=http-fetch-pinned.js.map
@@ -11,7 +11,10 @@
11
11
  * AbortController, response truncation, default User-Agent.
12
12
  *
13
13
  * Architectural constraints (load-bearing):
14
- * - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in)
14
+ * - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in):
15
+ * IP literals by text here, and every resolved address of a hostname at connect time, on the
16
+ * connection itself (http-fetch-pinned.ts), so DNS that points at, or rebinds to, a private
17
+ * address is refused too
15
18
  * - DEFAULT-REFUSES Authorization / Cookie / X-Auth-* (CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH=1 opt-in)
16
19
  * - hard timeout 30s default, 60s ceiling
17
20
  * - response truncated to 256KB default, 1MB ceiling
@@ -20,6 +23,8 @@
20
23
  * @module @claude-flow/cli/mcp-tools/http-fetch
21
24
  */
22
25
  import type { MCPTool } from './types.js';
26
+ import { type PinnedInit } from './http-fetch-pinned.js';
27
+ export { isPrivateOrLoopback } from './http-fetch-pinned.js';
23
28
  export declare class HttpFetchValidationError extends Error {
24
29
  readonly code: string;
25
30
  constructor(message: string, code: string);
@@ -45,11 +50,15 @@ export interface HttpFetchResult {
45
50
  error?: string;
46
51
  errorCode?: string;
47
52
  }
53
+ /** How one request is sent; tests replace it to script responses. The default pins each connection to checked addresses. */
54
+ export type HttpFetchRequest = (url: string, init: PinnedInit) => Promise<Response>;
48
55
  /**
49
56
  * Pure execution path so tests can call it without going through the MCP
50
57
  * dispatcher. Returns a result object (does not throw on validation failure
51
58
  * — it returns success: false with an errorCode).
52
59
  */
53
- export declare function httpFetchExecute(input: Record<string, unknown>): Promise<HttpFetchResult>;
60
+ export declare function httpFetchExecute(input: Record<string, unknown>, deps?: {
61
+ request?: HttpFetchRequest;
62
+ }): Promise<HttpFetchResult>;
54
63
  export declare const httpFetchTools: MCPTool[];
55
64
  //# sourceMappingURL=http-fetch-tools.d.ts.map