@claude-flow/cli 3.54.1 → 3.55.0

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 (60) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/bin/cli.js +1 -1
  3. package/bin/mcp-server.js +1 -1
  4. package/catalog-manifest.json +2 -2
  5. package/dist/src/commands/autopilot.js +2 -2
  6. package/dist/src/commands/daemon.js +21 -1
  7. package/dist/src/commands/hive-mind.js +7 -0
  8. package/dist/src/commands/mcp.js +35 -1
  9. package/dist/src/commands/policy.js +10 -2
  10. package/dist/src/mcp-server.d.ts +33 -0
  11. package/dist/src/mcp-server.js +96 -4
  12. package/dist/src/mcp-tools/hive-mind-tools.d.ts +2 -4
  13. package/dist/src/mcp-tools/hive-mind-tools.js +214 -40
  14. package/dist/src/missions/schemas.d.ts +125 -125
  15. package/dist/src/mods/command-registry/schema.d.ts +199 -199
  16. package/dist/src/parser.d.ts +3 -0
  17. package/dist/src/parser.js +64 -13
  18. package/dist/src/production/retry.js +1 -1
  19. package/dist/src/services/bounded-worker-pool.js +3 -0
  20. package/dist/src/services/config-file-manager.d.ts +3 -0
  21. package/dist/src/services/config-file-manager.js +24 -7
  22. package/dist/src/services/policy-ledger-anchor.d.ts +42 -0
  23. package/dist/src/services/policy-ledger-anchor.js +163 -0
  24. package/dist/src/services/policy-runtime.d.ts +11 -5
  25. package/dist/src/services/policy-runtime.js +70 -15
  26. package/dist/src/services/worker-daemon.d.ts +1 -0
  27. package/dist/src/services/worker-daemon.js +12 -0
  28. package/dist/src/services/worker-queue.js +11 -2
  29. package/node_modules/@claude-flow/mcp/dist/connection-pool.d.ts.map +1 -1
  30. package/node_modules/@claude-flow/mcp/dist/connection-pool.js +5 -2
  31. package/node_modules/@claude-flow/mcp/dist/connection-pool.js.map +1 -1
  32. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts +7 -1
  33. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
  34. package/node_modules/@claude-flow/mcp/dist/transport/http.js +60 -47
  35. package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
  36. package/node_modules/@claude-flow/mcp/package.json +1 -1
  37. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js +1 -1
  38. package/node_modules/@claude-flow/plugin-agent-federation/dist/application/federation-coordinator.js.map +1 -1
  39. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  40. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts +3 -0
  41. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.d.ts.map +1 -1
  42. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js +4 -1
  43. package/node_modules/@claude-flow/plugin-agent-federation/dist/domain/services/routing-service.js.map +1 -1
  44. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.d.ts.map +1 -1
  45. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js +1 -0
  46. package/node_modules/@claude-flow/plugin-agent-federation/dist/plugin.js.map +1 -1
  47. package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -1
  48. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +11 -1
  49. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  50. package/node_modules/@claude-flow/security/dist/policy/engine.js +19 -3
  51. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  52. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +4 -2
  53. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  54. package/node_modules/@claude-flow/security/package.json +1 -1
  55. package/package.json +7 -7
  56. package/plugins/ruflo-metaharness/.claude-plugin/plugin.json +1 -1
  57. package/plugins/ruflo-metaharness/scripts/_invoke.mjs +80 -7
  58. package/plugins/ruflo-metaharness/scripts/smoke.sh +4 -4
  59. package/plugins/ruflo-metaharness/scripts/test-cli-resolution-3558.mjs +141 -0
  60. package/plugins/ruflo-metaharness/scripts/test-no-tool-time-npx.mjs +5 -0
@@ -94,6 +94,9 @@ export declare class CommandParser {
94
94
  * commit message.)
95
95
  */
96
96
  private getScopedBooleanFlags;
97
+ /** Resolve declared types from broad to narrow without borrowing string
98
+ * declarations from unrelated registered commands. */
99
+ private getFlagScope;
97
100
  private getBooleanFlags;
98
101
  private applyDefaults;
99
102
  validateFlags(flags: ParsedFlags, command?: Command): string[];
@@ -135,14 +135,27 @@ export class CommandParser {
135
135
  // with `daemon` left as a positional.
136
136
  let resolvedCmd;
137
137
  let resolvedSub;
138
+ const resolvedCommands = [];
138
139
  let sawFirstPositional = false;
139
- for (const arg of args) {
140
- if (arg.startsWith('-'))
140
+ for (let index = 0; index < args.length; index++) {
141
+ const arg = args[index];
142
+ if (arg === '--')
143
+ break;
144
+ if (arg.startsWith('-')) {
145
+ // Pass 2 recognizes only contiguous subcommands after the root.
146
+ if (sawFirstPositional)
147
+ break;
148
+ // Skip consumed values as well as flags: a global option value is not
149
+ // the command slot, even when it happens to name another command.
150
+ const scope = this.getFlagScope(resolvedCommands);
151
+ index = this.parseFlag(args, index, scope.aliases, scope.booleanFlags, scope.stringFlags).nextIndex - 1;
141
152
  continue;
153
+ }
142
154
  if (!sawFirstPositional) {
143
155
  sawFirstPositional = true;
144
156
  if (this.commands.has(arg)) {
145
157
  resolvedCmd = this.commands.get(arg);
158
+ resolvedCommands.push(resolvedCmd);
146
159
  continue;
147
160
  }
148
161
  // Lazy command: we know its name but not its subcommands. Stop the
@@ -153,14 +166,21 @@ export class CommandParser {
153
166
  // Unknown first positional — not a command. Stop walking.
154
167
  break;
155
168
  }
156
- if (resolvedCmd && !resolvedSub && resolvedCmd.subcommands) {
157
- resolvedSub = resolvedCmd.subcommands.find(sc => sc.name === arg || sc.aliases?.includes(arg));
169
+ // Match the same supported command depth as Pass 2, with each option
170
+ // declaration taking precedence over its parent scope.
171
+ const parent = resolvedCommands[resolvedCommands.length - 1];
172
+ if (resolvedCommands.length < 4 && parent?.subcommands) {
173
+ const child = parent.subcommands.find(sc => sc.name === arg || sc.aliases?.includes(arg));
174
+ if (child) {
175
+ resolvedCommands.push(child);
176
+ resolvedSub ??= child;
177
+ continue;
178
+ }
158
179
  }
180
+ // A positional that is not the next subcommand ends the command path.
181
+ break;
159
182
  }
160
- // Pass 2: Build aliases scoped to the resolved subcommand
161
- // Subcommand-specific aliases take priority over global ones
162
- const aliases = this.buildScopedAliases(resolvedSub || resolvedCmd);
163
- const booleanFlags = this.getScopedBooleanFlags(resolvedSub || resolvedCmd);
183
+ const { aliases, booleanFlags, stringFlags } = this.getFlagScope(resolvedCommands);
164
184
  let i = 0;
165
185
  let parsingFlags = true;
166
186
  while (i < args.length) {
@@ -173,7 +193,7 @@ export class CommandParser {
173
193
  }
174
194
  // Handle flags
175
195
  if (parsingFlags && arg.startsWith('-')) {
176
- const parseResult = this.parseFlag(args, i, aliases, booleanFlags);
196
+ const parseResult = this.parseFlag(args, i, aliases, booleanFlags, stringFlags);
177
197
  // Apply to result flags
178
198
  Object.assign(result.flags, parseResult.flags);
179
199
  i = parseResult.nextIndex;
@@ -231,7 +251,7 @@ export class CommandParser {
231
251
  this.applyDefaults(result.flags, resolvedCmd, resolvedSub);
232
252
  return result;
233
253
  }
234
- parseFlag(args, index, aliases, booleanFlags) {
254
+ parseFlag(args, index, aliases, booleanFlags, stringFlags) {
235
255
  const flags = { _: [] };
236
256
  const arg = args[index];
237
257
  let nextIndex = index + 1;
@@ -242,7 +262,8 @@ export class CommandParser {
242
262
  // --flag=value
243
263
  const key = arg.slice(2, equalIndex);
244
264
  const value = arg.slice(equalIndex + 1);
245
- flags[this.normalizeKey(key)] = this.parseValue(value);
265
+ const normalizedKey = this.normalizeKey(key);
266
+ flags[normalizedKey] = stringFlags.has(normalizedKey) ? value : this.parseValue(value);
246
267
  }
247
268
  else if (arg.startsWith('--no-')) {
248
269
  // --no-flag (boolean negation)
@@ -266,7 +287,7 @@ export class CommandParser {
266
287
  }
267
288
  }
268
289
  else if (nextIndex < args.length && this.isFlagValue(args[nextIndex])) {
269
- flags[normalizedKey] = this.parseValue(args[nextIndex]);
290
+ flags[normalizedKey] = stringFlags.has(normalizedKey) ? args[nextIndex] : this.parseValue(args[nextIndex]);
270
291
  nextIndex++;
271
292
  }
272
293
  else {
@@ -293,7 +314,7 @@ export class CommandParser {
293
314
  }
294
315
  }
295
316
  else if (nextIndex < args.length && this.isFlagValue(args[nextIndex])) {
296
- flags[normalizedKey] = this.parseValue(args[nextIndex]);
317
+ flags[normalizedKey] = stringFlags.has(normalizedKey) ? args[nextIndex] : this.parseValue(args[nextIndex]);
297
318
  nextIndex++;
298
319
  }
299
320
  else {
@@ -439,6 +460,36 @@ export class CommandParser {
439
460
  }
440
461
  return flags;
441
462
  }
463
+ /** Resolve declared types from broad to narrow without borrowing string
464
+ * declarations from unrelated registered commands. */
465
+ getFlagScope(commands) {
466
+ const aliases = this.buildScopedAliases(commands[commands.length - 1]);
467
+ const booleanFlags = this.getScopedBooleanFlags(commands[commands.length - 1]);
468
+ const stringFlags = new Set((this.options.stringFlags ?? []).map(flag => this.normalizeKey(flag)));
469
+ for (const command of commands) {
470
+ for (const option of command.options ?? []) {
471
+ if (option.short)
472
+ aliases[option.short] = option.name;
473
+ }
474
+ }
475
+ const layers = [this.globalOptions, ...commands.map(command => command.options ?? [])];
476
+ for (const options of layers) {
477
+ for (const option of options) {
478
+ const key = this.normalizeKey(option.name);
479
+ if (option.type === 'string')
480
+ stringFlags.add(key);
481
+ else if (option.type)
482
+ stringFlags.delete(key);
483
+ if (option.type === 'boolean')
484
+ booleanFlags.add(key);
485
+ else if (option.type)
486
+ booleanFlags.delete(key);
487
+ }
488
+ }
489
+ for (const flag of stringFlags)
490
+ booleanFlags.delete(flag);
491
+ return { aliases, booleanFlags, stringFlags };
492
+ }
442
493
  getBooleanFlags() {
443
494
  const flags = new Set();
444
495
  for (const opt of this.globalOptions) {
@@ -116,7 +116,7 @@ export async function withRetry(fn, config = {}, strategy = 'exponential') {
116
116
  };
117
117
  }
118
118
  catch (error) {
119
- lastError = error;
119
+ lastError = error instanceof Error ? error : new Error(String(error));
120
120
  // Check if we should retry
121
121
  if (!shouldRetryError(lastError, attempt, finalConfig)) {
122
122
  return {
@@ -20,6 +20,9 @@ export async function runBoundedPool(tasks, options) {
20
20
  const controller = new AbortController();
21
21
  const onAbort = () => controller.abort(options.signal?.reason ?? new Error('cancelled'));
22
22
  options.signal?.addEventListener('abort', onAbort, { once: true });
23
+ // Abort events are not replayed for signals cancelled before registration.
24
+ if (options.signal?.aborted)
25
+ onAbort();
23
26
  const timer = options.timeoutMs && options.timeoutMs > 0
24
27
  ? setTimeout(() => controller.abort(new Error('worker-pool-timeout')), options.timeoutMs)
25
28
  : undefined;
@@ -3,6 +3,7 @@
3
3
  * Shared JSON config file persistence with atomic writes and Zod validation
4
4
  */
5
5
  export declare class ConfigFileManager {
6
+ private configCwd;
6
7
  private configPath;
7
8
  private config;
8
9
  /** Find config file in search paths starting from cwd */
@@ -27,6 +28,8 @@ export declare class ConfigFileManager {
27
28
  getConfigPath(): string | null;
28
29
  /** Get default config */
29
30
  getDefaults(): Record<string, unknown>;
31
+ /** Cached values and file paths belong to one resolved project directory. */
32
+ private selectProject;
30
33
  /** Atomic write: write to .tmp then rename */
31
34
  private writeAtomic;
32
35
  }
@@ -54,6 +54,7 @@ const DEFAULT_CONFIG = {
54
54
  },
55
55
  };
56
56
  export class ConfigFileManager {
57
+ configCwd = null;
57
58
  configPath = null;
58
59
  config = null;
59
60
  /** Find config file in search paths starting from cwd */
@@ -73,6 +74,7 @@ export class ConfigFileManager {
73
74
  }
74
75
  /** Load config from file, returns null if not found */
75
76
  load(cwd) {
77
+ this.selectProject(cwd);
76
78
  this.configPath = this.findConfig(cwd);
77
79
  if (!this.configPath) {
78
80
  this.config = null;
@@ -101,10 +103,11 @@ export class ConfigFileManager {
101
103
  }
102
104
  /** Get the current config, loading if needed */
103
105
  getConfig(cwd) {
106
+ this.selectProject(cwd);
104
107
  if (this.config === null) {
105
108
  this.load(cwd);
106
109
  }
107
- return this.config ?? { ...DEFAULT_CONFIG };
110
+ return this.config ?? structuredClone(DEFAULT_CONFIG);
108
111
  }
109
112
  /** Get a nested config value by dot-separated key */
110
113
  get(cwd, key) {
@@ -117,6 +120,7 @@ export class ConfigFileManager {
117
120
  if (key.split('.').some(part => part === '__proto__' || part === 'constructor' || part === 'prototype')) {
118
121
  throw new Error(`Unsafe configuration key: ${key}`);
119
122
  }
123
+ this.selectProject(cwd);
120
124
  // A targeted update must not persist unrelated defaults (notably the
121
125
  // default memory path, which would relocate an existing memory store).
122
126
  const config = this.config ?? this.load(cwd) ?? {};
@@ -137,17 +141,20 @@ export class ConfigFileManager {
137
141
  if (fs.existsSync(targetPath) && !force) {
138
142
  throw new Error(`Config file already exists: ${targetPath}. Use --force to overwrite.`);
139
143
  }
140
- const config = { ...DEFAULT_CONFIG, ...overrides };
144
+ const config = { ...structuredClone(DEFAULT_CONFIG), ...overrides };
141
145
  this.writeAtomic(targetPath, config);
146
+ this.selectProject(cwd);
142
147
  this.config = config;
143
148
  this.configPath = targetPath;
144
149
  return targetPath;
145
150
  }
146
151
  /** Reset config to defaults */
147
152
  reset(cwd) {
148
- const targetPath = this.configPath ?? path.resolve(cwd, CONFIG_FILENAMES[0]);
149
- this.writeAtomic(targetPath, DEFAULT_CONFIG);
150
- this.config = { ...DEFAULT_CONFIG };
153
+ this.selectProject(cwd);
154
+ const targetPath = this.findConfig(cwd) ?? path.resolve(cwd, CONFIG_FILENAMES[0]);
155
+ const config = structuredClone(DEFAULT_CONFIG);
156
+ this.writeAtomic(targetPath, config);
157
+ this.config = config;
151
158
  this.configPath = targetPath;
152
159
  return targetPath;
153
160
  }
@@ -174,7 +181,8 @@ export class ConfigFileManager {
174
181
  if (typeof imported !== 'object' || imported === null || Array.isArray(imported)) {
175
182
  throw new Error('Import file must contain a JSON object');
176
183
  }
177
- const targetPath = this.configPath ?? path.resolve(cwd, CONFIG_FILENAMES[0]);
184
+ this.selectProject(cwd);
185
+ const targetPath = this.findConfig(cwd) ?? path.resolve(cwd, CONFIG_FILENAMES[0]);
178
186
  this.writeAtomic(targetPath, imported);
179
187
  this.config = imported;
180
188
  this.configPath = targetPath;
@@ -185,7 +193,16 @@ export class ConfigFileManager {
185
193
  }
186
194
  /** Get default config */
187
195
  getDefaults() {
188
- return { ...DEFAULT_CONFIG };
196
+ return structuredClone(DEFAULT_CONFIG);
197
+ }
198
+ /** Cached values and file paths belong to one resolved project directory. */
199
+ selectProject(cwd) {
200
+ const resolved = path.resolve(cwd);
201
+ if (this.configCwd !== resolved) {
202
+ this.configCwd = resolved;
203
+ this.config = null;
204
+ this.configPath = null;
205
+ }
189
206
  }
190
207
  /** Atomic write: write to .tmp then rename */
191
208
  writeAtomic(filePath, data) {
@@ -0,0 +1,42 @@
1
+ import type { PolicyState } from '@claude-flow/security';
2
+ export type AnchorEvent = 'append' | 'migrated-from-state' | 'establish-anchor';
3
+ export interface AnchorEntry {
4
+ seq: number;
5
+ length: number;
6
+ headHash: string | null;
7
+ prevAnchorHash: string | null;
8
+ ts: number;
9
+ event: AnchorEvent;
10
+ by?: string;
11
+ hash: string;
12
+ }
13
+ export type AnchorAssessment = {
14
+ kind: 'absent';
15
+ } | {
16
+ kind: 'ok';
17
+ last: AnchorEntry;
18
+ behind: boolean;
19
+ } | {
20
+ kind: 'fail';
21
+ error: string;
22
+ };
23
+ export type AnchorWriter = (file: string, value: unknown) => Promise<void>;
24
+ export declare const anchorLogPath: (projectRoot: string) => string;
25
+ /**
26
+ * How the stored anchors relate to `state`; read-only. `rebuild` is the
27
+ * explicit-establish path: an unreadable log or mirror is treated as absent, but
28
+ * whatever readable anchor remains must still agree with the chain, so it can
29
+ * never bless a truncation the anchors still remember.
30
+ */
31
+ export declare function assessAnchors(projectRoot: string, state: PolicyState, rebuild?: boolean): AnchorAssessment;
32
+ /**
33
+ * Append an entry for the current state if the newest entry does not already
34
+ * describe it. Must run under the policy lock. Throws if the log is corrupt:
35
+ * only an explicit establish may replace a corrupt log.
36
+ */
37
+ export declare function recordAnchor(projectRoot: string, state: PolicyState, write: AnchorWriter, event?: AnchorEvent, by?: string): Promise<void>;
38
+ export declare function anchorPosture(projectRoot: string): {
39
+ log: boolean;
40
+ mirror: boolean;
41
+ };
42
+ //# sourceMappingURL=policy-ledger-anchor.d.ts.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Second, hash-chained copy of the policy ledger anchor (#3602, ADR-475).
3
+ *
4
+ * The primary anchor (`ledgerLength` / `ledgerHead`) lives inside state.json,
5
+ * the same file it protects, so deleting it together with receipts used to
6
+ * make `verify` bless the truncated chain. This module keeps an append-only
7
+ * chain of anchor entries in a SEPARATE file plus a mirror of the newest entry
8
+ * outside the project directory (~/.config/ruflo/policy-trust/<project>/).
9
+ *
10
+ * Threat model: this detects any tampering confined to state.json, and any
11
+ * tampering confined to the project directory. It does NOT stop an attacker
12
+ * who can rewrite both the project directory and the user's home directory.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { userInfo } from 'node:os';
18
+ const LOG_FILE = 'ledger-anchors.json';
19
+ const MIRROR_FILE = 'ledger-anchor-head.json';
20
+ export const anchorLogPath = (projectRoot) => join(resolve(projectRoot), '.claude-flow', 'policy', LOG_FILE);
21
+ function mirrorPath(projectRoot) {
22
+ const id = createHash('sha256').update(realpathSync(projectRoot)).digest('hex');
23
+ return join(userInfo().homedir, '.config', 'ruflo', 'policy-trust', id, MIRROR_FILE);
24
+ }
25
+ function entryHash(entry) {
26
+ return createHash('sha256').update(JSON.stringify([
27
+ entry.seq, entry.length, entry.headHash, entry.prevAnchorHash, entry.ts, entry.event, entry.by ?? null,
28
+ ])).digest('hex');
29
+ }
30
+ function validEntry(value, seq, prev) {
31
+ const e = value;
32
+ return !!e && typeof e === 'object'
33
+ && e.seq === seq
34
+ && Number.isInteger(e.length) && e.length >= 0
35
+ && (e.headHash === null || typeof e.headHash === 'string')
36
+ && e.prevAnchorHash === prev
37
+ && typeof e.ts === 'number'
38
+ && ['append', 'migrated-from-state', 'establish-anchor'].includes(e.event)
39
+ && typeof e.hash === 'string'
40
+ && e.hash === entryHash({ ...e, hash: undefined });
41
+ }
42
+ function readLog(projectRoot) {
43
+ const file = anchorLogPath(projectRoot);
44
+ if (!existsSync(file))
45
+ return [];
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
48
+ if (parsed.version !== 1 || !Array.isArray(parsed.entries))
49
+ return 'corrupt';
50
+ let prev = null;
51
+ for (let i = 0; i < parsed.entries.length; i++) {
52
+ if (!validEntry(parsed.entries[i], i, prev))
53
+ return 'corrupt';
54
+ prev = parsed.entries[i].hash;
55
+ }
56
+ return parsed.entries;
57
+ }
58
+ catch {
59
+ return 'corrupt';
60
+ }
61
+ }
62
+ function readMirror(projectRoot) {
63
+ const file = mirrorPath(projectRoot);
64
+ if (!existsSync(file))
65
+ return null;
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
68
+ return typeof parsed?.hash === 'string' && parsed.hash === entryHash({ ...parsed, hash: undefined })
69
+ ? parsed
70
+ : 'corrupt';
71
+ }
72
+ catch {
73
+ return 'corrupt';
74
+ }
75
+ }
76
+ /**
77
+ * How the stored anchors relate to `state`; read-only. `rebuild` is the
78
+ * explicit-establish path: an unreadable log or mirror is treated as absent, but
79
+ * whatever readable anchor remains must still agree with the chain, so it can
80
+ * never bless a truncation the anchors still remember.
81
+ */
82
+ export function assessAnchors(projectRoot, state, rebuild = false) {
83
+ let entries = readLog(projectRoot);
84
+ if (entries === 'corrupt') {
85
+ if (!rebuild)
86
+ return { kind: 'fail', error: 'policy-anchor-log-corrupt' };
87
+ entries = [];
88
+ }
89
+ let mirror = readMirror(projectRoot);
90
+ if (mirror === 'corrupt') {
91
+ if (!rebuild)
92
+ return { kind: 'fail', error: 'policy-anchor-mirror-corrupt' };
93
+ mirror = null;
94
+ }
95
+ if (entries.length === 0) {
96
+ if (!mirror)
97
+ return { kind: 'absent' };
98
+ if (!rebuild)
99
+ return { kind: 'fail', error: 'policy-anchor-log-missing' };
100
+ if (state.receipts.length < mirror.length)
101
+ return { kind: 'fail', error: 'policy-ledger-truncated' };
102
+ if (mirror.length > 0 && state.receipts[mirror.length - 1].hash !== mirror.headHash) {
103
+ return { kind: 'fail', error: 'policy-ledger-anchor-mismatch' };
104
+ }
105
+ return { kind: 'absent' };
106
+ }
107
+ if (mirror && !entries.some((entry) => entry.hash === mirror.hash)) {
108
+ return { kind: 'fail', error: 'policy-anchor-log-rolled-back' };
109
+ }
110
+ const last = entries[entries.length - 1];
111
+ for (const anchor of mirror ? [last, mirror] : [last]) {
112
+ if (state.receipts.length < anchor.length)
113
+ return { kind: 'fail', error: 'policy-ledger-truncated' };
114
+ if (anchor.length > 0 && state.receipts[anchor.length - 1].hash !== anchor.headHash) {
115
+ return { kind: 'fail', error: 'policy-ledger-anchor-mismatch' };
116
+ }
117
+ }
118
+ return { kind: 'ok', last, behind: state.receipts.length > last.length };
119
+ }
120
+ /**
121
+ * Append an entry for the current state if the newest entry does not already
122
+ * describe it. Must run under the policy lock. Throws if the log is corrupt:
123
+ * only an explicit establish may replace a corrupt log.
124
+ */
125
+ export async function recordAnchor(projectRoot, state, write, event = 'append', by) {
126
+ if (state.receipts.length === 0)
127
+ return;
128
+ const loaded = readLog(projectRoot);
129
+ const entries = loaded === 'corrupt' ? [] : loaded;
130
+ if (loaded === 'corrupt' && event !== 'establish-anchor')
131
+ throw new Error('policy-anchor-log-corrupt');
132
+ const last = entries[entries.length - 1];
133
+ const length = state.receipts.length;
134
+ const headHash = state.receipts[length - 1].hash;
135
+ let tip = last;
136
+ if (!last || last.length !== length || last.headHash !== headHash) {
137
+ const base = {
138
+ seq: entries.length,
139
+ length,
140
+ headHash,
141
+ prevAnchorHash: last?.hash ?? null,
142
+ ts: Date.now(),
143
+ event,
144
+ ...(by ? { by } : {}),
145
+ };
146
+ tip = { ...base, hash: entryHash(base) };
147
+ await write(anchorLogPath(projectRoot), { version: 1, entries: [...entries, tip] });
148
+ }
149
+ try {
150
+ const mirror = mirrorPath(projectRoot);
151
+ mkdirSync(dirname(mirror), { recursive: true, mode: 0o700 });
152
+ await write(mirror, tip);
153
+ }
154
+ catch (error) {
155
+ // A read-only home must not stop the ledger; verify reports the weaker
156
+ // posture (no mirror) rather than failing closed on every decision.
157
+ process.stderr.write(`[policy] anchor mirror not written: ${error.message}\n`);
158
+ }
159
+ }
160
+ export function anchorPosture(projectRoot) {
161
+ return { log: existsSync(anchorLogPath(projectRoot)), mirror: existsSync(mirrorPath(projectRoot)) };
162
+ }
163
+ //# sourceMappingURL=policy-ledger-anchor.js.map
@@ -22,13 +22,19 @@ export declare function issuePolicyApproval(approval: Omit<PolicyApproval, 'uses
22
22
  issuedAt?: number;
23
23
  }, projectRoot?: string, approvalIssuerVerifier?: (issuer: string) => boolean): Promise<PolicyApproval>;
24
24
  export declare function revokePolicyApproval(id: string, projectRoot?: string): Promise<boolean>;
25
+ export type PolicyLedgerVerification = ReturnType<AgenticPolicyEngine['verifyLedger']> & {
26
+ /** How the second anchor was brought in line during this call. */
27
+ secondaryAnchor?: 'recorded-from-state' | 'restored-primary' | 'established-explicitly';
28
+ };
25
29
  /**
26
- * Read-only verification (#3568). It must not run inside
27
- * `withPolicyTransaction`, whose own post-operation check throws a generic
28
- * `policy-ledger-verification-failed` and hides which check failed. The only
29
- * write is persisting an anchor established for a pre-anchor ledger.
30
+ * Verification (#3568, #3602). It must not run inside `withPolicyTransaction`,
31
+ * whose own post-operation check throws a generic
32
+ * `policy-ledger-verification-failed` and hides which check failed. It writes
33
+ * only to bring the second anchor in line, or on an explicit `establishAnchor`.
30
34
  */
31
- export declare function verifyPolicyLedger(projectRoot?: string): Promise<ReturnType<AgenticPolicyEngine['verifyLedger']>>;
35
+ export declare function verifyPolicyLedger(projectRoot?: string, options?: {
36
+ establishAnchor?: boolean;
37
+ }): Promise<PolicyLedgerVerification>;
32
38
  export declare function authorizeMcpTool(toolName: string, input: Record<string, unknown>, context?: Record<string, unknown>, attributes?: Readonly<{
33
39
  actionType?: string;
34
40
  network?: boolean;
@@ -5,6 +5,7 @@ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync,
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { hostname, userInfo } from 'node:os';
7
7
  import { syncPolicyProjection } from '../mods/policy-projection.js';
8
+ import { assessAnchors, recordAnchor } from './policy-ledger-anchor.js';
8
9
  const POLICY_DIR = join('.claude-flow', 'policy');
9
10
  const POLICY_FILE = 'state.json';
10
11
  const LOCK_FILE = 'state.lock';
@@ -231,8 +232,11 @@ function verifyStateAnchor(projectRoot, state) {
231
232
  throw new Error('policy-state-authentication-failed');
232
233
  }
233
234
  }
234
- async function writePolicyState(projectRoot, statePath, state) {
235
+ async function writePolicyState(projectRoot, statePath, state, anchorEvent = 'append', by) {
235
236
  await writePolicyStateFiles(projectRoot, statePath, state);
237
+ // #3602: the second anchor is written after the state, so a crash leaves the
238
+ // state AHEAD of the log (caught up on the next write), never behind it.
239
+ await recordAnchor(projectRoot, state, writeJsonAtomic, anchorEvent, by);
236
240
  // ADR-404: the ruflo mod reads Claude Code tool rules from a small
237
241
  // projection, never from state.json. Written only after the state and its
238
242
  // anchor are safely down; a failure here never fails the state write. A
@@ -350,20 +354,26 @@ export async function withPolicyTransaction(projectRoot, operation, options = {}
350
354
  mkdirSync(target.dir, { recursive: true, mode: 0o700 });
351
355
  const release = await acquireLock(target.lock);
352
356
  try {
353
- const engine = AgenticPolicyEngine.fromState(loadPolicyState(projectRoot), {
357
+ const loaded = loadPolicyState(projectRoot);
358
+ const engine = AgenticPolicyEngine.fromState(loaded, {
354
359
  signingKey: process.env.CLAUDE_FLOW_POLICY_SIGNING_KEY,
355
360
  keyId: process.env.CLAUDE_FLOW_POLICY_KEY_ID,
356
361
  evidenceVerifier: verifyPolicyEvidence,
357
362
  approvalIssuerVerifier: options.approvalIssuerVerifier,
358
363
  });
364
+ // #3602: refuse to operate on a ledger whose anchors are missing or
365
+ // disagree; a restored primary anchor is persisted by the write below.
366
+ const gate = reconcileLedger(projectRoot, engine, {});
367
+ if (!gate.result.valid) {
368
+ throw new Error(gate.result.error === 'anchor-missing'
369
+ ? `policy-ledger-anchor-missing: receipts exist but no ledger anchor was found. If this ledger predates anchors, run \`ruflo policy verify --establish-anchor\`; otherwise treat it as tampering.`
370
+ : gate.result.error ?? 'policy-ledger-verification-failed');
371
+ }
359
372
  const result = await operation(engine);
360
- // Verify before exporting: verification establishes the ledger anchor
361
- // (#3568) on state written before the anchor existed, and that anchor
362
- // must be part of what is persisted.
363
373
  if (!engine.verifyLedger().valid)
364
374
  throw new Error('policy-ledger-verification-failed');
365
375
  const nextState = engine.exportState();
366
- await writePolicyState(projectRoot, target.state, nextState);
376
+ await writePolicyState(projectRoot, target.state, nextState, gate.event);
367
377
  return result;
368
378
  }
369
379
  finally {
@@ -416,12 +426,56 @@ export async function revokePolicyApproval(id, projectRoot = process.cwd()) {
416
426
  return withPolicyTransaction(projectRoot, (engine) => engine.revokeApproval(id));
417
427
  }
418
428
  /**
419
- * Read-only verification (#3568). It must not run inside
420
- * `withPolicyTransaction`, whose own post-operation check throws a generic
421
- * `policy-ledger-verification-failed` and hides which check failed. The only
422
- * write is persisting an anchor established for a pre-anchor ledger.
429
+ * Decide whether the ledger may be trusted, using the chain, the primary anchor
430
+ * in state.json and the second anchor (#3602). It never creates an anchor over
431
+ * existing receipts on its own: only an authentic second anchor (restoring a
432
+ * deleted primary) or an explicit `establishAnchor` does.
433
+ */
434
+ function reconcileLedger(projectRoot, engine, options) {
435
+ const state = engine.exportState();
436
+ const length = state.receipts.length;
437
+ const primary = typeof state.ledgerLength === 'number';
438
+ const base = engine.verifyLedger();
439
+ const fail = (error) => ({ result: { valid: false, length, error }, persist: false });
440
+ if (!base.valid && base.error !== 'anchor-missing')
441
+ return { result: base, persist: false };
442
+ const assessed = assessAnchors(projectRoot, state, options.establishAnchor === true);
443
+ if (assessed.kind === 'fail')
444
+ return fail(assessed.error);
445
+ if (primary) {
446
+ if (assessed.kind === 'absent' && length > 0) {
447
+ return options.establishAnchor
448
+ ? { result: { ...base, secondaryAnchor: 'established-explicitly' }, persist: true, event: 'establish-anchor' }
449
+ : { result: { ...base, secondaryAnchor: 'recorded-from-state' }, persist: true, event: 'migrated-from-state' };
450
+ }
451
+ return { result: base, persist: assessed.kind === 'ok' && assessed.behind, event: 'append' };
452
+ }
453
+ // No primary anchor.
454
+ if (length === 0)
455
+ return { result: { valid: true, length, state: 'empty' }, persist: false };
456
+ if (assessed.kind === 'ok') {
457
+ // The second anchor is authentic and agrees with the chain: the deleted
458
+ // primary can be restored from it. Truncation was already rejected above.
459
+ engine.verifyLedger({ establishAnchor: true });
460
+ return { result: { valid: true, length, secondaryAnchor: 'restored-primary' }, persist: true, event: 'append' };
461
+ }
462
+ if (options.establishAnchor) {
463
+ const established = engine.verifyLedger({ establishAnchor: true });
464
+ return {
465
+ result: { ...established, secondaryAnchor: 'established-explicitly' },
466
+ persist: true,
467
+ event: 'establish-anchor',
468
+ };
469
+ }
470
+ return fail('anchor-missing');
471
+ }
472
+ /**
473
+ * Verification (#3568, #3602). It must not run inside `withPolicyTransaction`,
474
+ * whose own post-operation check throws a generic
475
+ * `policy-ledger-verification-failed` and hides which check failed. It writes
476
+ * only to bring the second anchor in line, or on an explicit `establishAnchor`.
423
477
  */
424
- export async function verifyPolicyLedger(projectRoot = process.cwd()) {
478
+ export async function verifyPolicyLedger(projectRoot = process.cwd(), options = {}) {
425
479
  const target = paths(projectRoot);
426
480
  mkdirSync(target.dir, { recursive: true, mode: 0o700 });
427
481
  const release = await acquireLock(target.lock);
@@ -430,11 +484,12 @@ export async function verifyPolicyLedger(projectRoot = process.cwd()) {
430
484
  signingKey: process.env.CLAUDE_FLOW_POLICY_SIGNING_KEY,
431
485
  keyId: process.env.CLAUDE_FLOW_POLICY_KEY_ID,
432
486
  });
433
- const result = engine.verifyLedger();
434
- if (result.anchor === 'established-now') {
435
- await writePolicyState(projectRoot, target.state, engine.exportState());
487
+ const outcome = reconcileLedger(projectRoot, engine, options);
488
+ if (outcome.persist) {
489
+ const by = outcome.event === 'establish-anchor' ? userInfo().username : undefined;
490
+ await writePolicyState(projectRoot, target.state, engine.exportState(), outcome.event, by);
436
491
  }
437
- return result;
492
+ return outcome.result;
438
493
  }
439
494
  finally {
440
495
  release();
@@ -67,6 +67,7 @@ export interface DaemonConfig {
67
67
  idleShutdownMs: number;
68
68
  aiWorkersEnabled: boolean;
69
69
  workers: WorkerConfig[];
70
+ enabledWorkers?: string[];
70
71
  }
71
72
  /**
72
73
  * Worker Daemon - Manages background workers with Node.js