@claude-flow/cli 3.56.10 → 3.57.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 (68) hide show
  1. package/.claude/helpers/.helpers-version +1 -1
  2. package/.claude/helpers/helpers.manifest.json +2 -2
  3. package/catalog-manifest.json +2 -2
  4. package/dist/src/commands/daemon.js +8 -2
  5. package/dist/src/commands/doctor.js +95 -18
  6. package/dist/src/commands/policy.js +6 -1
  7. package/dist/src/index.js +5 -5
  8. package/dist/src/init/settings-generator.js +4 -58
  9. package/dist/src/mcp-tools/memory-tools.d.ts +1 -1
  10. package/dist/src/mcp-tools/memory-tools.js +30 -5
  11. package/dist/src/mcp-tools/metaharness-tools.js +1 -1
  12. package/dist/src/mcp-tools/neural-tools.js +20 -50
  13. package/dist/src/mcp-tools/policy-tools.js +5 -3
  14. package/dist/src/memory/intelligence.js +80 -33
  15. package/dist/src/memory/stats-file-lock.d.ts +7 -0
  16. package/dist/src/memory/stats-file-lock.js +53 -0
  17. package/dist/src/services/distill-oracle.d.ts +1 -1
  18. package/dist/src/services/distill-oracle.js +2 -2
  19. package/dist/src/services/policy-anchor-log.d.ts +13 -0
  20. package/dist/src/services/policy-anchor-log.js +242 -0
  21. package/dist/src/services/policy-archive-scan.d.ts +7 -0
  22. package/dist/src/services/policy-archive-scan.js +50 -0
  23. package/dist/src/services/policy-ledger-anchor.d.ts +14 -8
  24. package/dist/src/services/policy-ledger-anchor.js +356 -40
  25. package/dist/src/services/policy-ledger-audit.d.ts +22 -0
  26. package/dist/src/services/policy-ledger-audit.js +260 -0
  27. package/dist/src/services/policy-ledger-record.d.ts +3 -0
  28. package/dist/src/services/policy-ledger-record.js +22 -0
  29. package/dist/src/services/policy-runtime.d.ts +1 -0
  30. package/dist/src/services/policy-runtime.js +261 -40
  31. package/node_modules/@claude-flow/security/dist/input-validator.d.ts +6 -6
  32. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +17 -1
  33. package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
  34. package/node_modules/@claude-flow/security/dist/policy/engine.js +99 -24
  35. package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
  36. package/node_modules/@claude-flow/security/dist/policy/types.d.ts +14 -1
  37. package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
  38. package/node_modules/@claude-flow/security/dist/policy/types.js +3 -1
  39. package/node_modules/@claude-flow/security/dist/policy/types.js.map +1 -1
  40. package/package.json +2 -2
  41. package/plugins/ruflo-console/dist/tui.mjs +153 -153
  42. package/plugins/ruflo-metaharness/hooks/screen.ts +7 -1
  43. package/plugins/ruflo-metaharness/scripts/_darwin.mjs +4 -4
  44. package/plugins/ruflo-metaharness/.claude-flow/daemon-state.json +0 -179
  45. package/plugins/ruflo-metaharness/.claude-flow/daemon.pid +0 -1
  46. package/plugins/ruflo-metaharness/.claude-flow/logs/daemon.log +0 -32
  47. package/plugins/ruflo-metaharness/.claude-flow/metrics/backup.json +0 -23
  48. package/plugins/ruflo-metaharness/.claude-flow/metrics/codebase-map.json +0 -11
  49. package/plugins/ruflo-metaharness/.claude-flow/metrics/consolidation.json +0 -16
  50. package/plugins/ruflo-metaharness/.claude-flow/metrics/harness-loop.json +0 -91
  51. package/plugins/ruflo-metaharness/.claude-flow/metrics/performance.json +0 -17
  52. package/plugins/ruflo-metaharness/.claude-flow/metrics/security-audit.json +0 -12
  53. package/plugins/ruflo-metaharness/.claude-flow/metrics/test-gaps.json +0 -8
  54. package/plugins/ruflo-metaharness/.claude-flow/neural/patterns.json +0 -2809
  55. package/plugins/ruflo-metaharness/.claude-flow/neural/stats.json +0 -6
  56. package/plugins/ruflo-metaharness/.claude-flow/policy/state.json +0 -11
  57. package/plugins/ruflo-metaharness/.swarm/agentdb-memory.db +0 -0
  58. package/plugins/ruflo-metaharness/.swarm/agentdb-memory.db-shm +0 -0
  59. package/plugins/ruflo-metaharness/.swarm/agentdb-memory.db-wal +0 -0
  60. package/plugins/ruflo-metaharness/.swarm/backups/memory-2026-10-14T16-09-38-164Z.db +0 -0
  61. package/plugins/ruflo-metaharness/.swarm/backups/store-agentdb-memory.db-2026-10-14T16-09-38-166Z.db +0 -0
  62. package/plugins/ruflo-metaharness/.swarm/ewc-fisher.json +0 -397
  63. package/plugins/ruflo-metaharness/.swarm/memory.db +0 -0
  64. package/plugins/ruflo-metaharness/.swarm/memory.db-shm +0 -0
  65. package/plugins/ruflo-metaharness/.swarm/memory.db-wal +0 -0
  66. package/plugins/ruflo-metaharness/agentdb.rvf +0 -0
  67. package/plugins/ruflo-metaharness/agentdb.rvf.lock +0 -0
  68. package/plugins/ruflo-metaharness/ruvector.db +0 -0
@@ -1,4 +1,5 @@
1
- import type { PolicyState } from '@claude-flow/security';
1
+ import { type AtomicJsonSource } from './policy-anchor-log.js';
2
+ import { type PolicyState } from '@claude-flow/security/policy/index';
2
3
  export type AnchorEvent = 'append' | 'migrated-from-state' | 'establish-anchor';
3
4
  export interface AnchorEntry {
4
5
  seq: number;
@@ -8,6 +9,15 @@ export interface AnchorEntry {
8
9
  ts: number;
9
10
  event: AnchorEvent;
10
11
  by?: string;
12
+ /**
13
+ * How much of the archive this anchor claims (`state.retainedFrom` at
14
+ * anchor time) and the hash of its newest archived receipt (#3892 Blocker
15
+ * 1). Absent on anchors written before this field existed, or for a
16
+ * ledger that has never been pruned — both resolve to a vacuous pass so
17
+ * the unpruned baseline stays byte-for-byte identical (see `entryHash`).
18
+ */
19
+ archivedThrough?: number;
20
+ archiveHeadHash?: string | null;
11
21
  hash: string;
12
22
  }
13
23
  export type AnchorAssessment = {
@@ -21,6 +31,7 @@ export type AnchorAssessment = {
21
31
  error: string;
22
32
  };
23
33
  export type AnchorWriter = (file: string, value: unknown) => Promise<void>;
34
+ export type StreamingAnchorWriter = (file: string, value: AtomicJsonSource) => Promise<void>;
24
35
  export declare const anchorLogPath: (projectRoot: string) => string;
25
36
  /**
26
37
  * Directory holding per-project trust material (anchor mirror, HMAC key).
@@ -30,19 +41,14 @@ export declare const anchorLogPath: (projectRoot: string) => string;
30
41
  * user who already has trust state under the legacy ~/.config keeps using it.
31
42
  */
32
43
  export declare function policyTrustRoot(): string;
33
- /**
34
- * How the stored anchors relate to `state`; read-only. `rebuild` is the
35
- * explicit-establish path: an unreadable log or mirror is treated as absent, but
36
- * whatever readable anchor remains must still agree with the chain, so it can
37
- * never bless a truncation the anchors still remember.
38
- */
44
+ /** Convert archive record-limit/UTF8 failures into structured gate failures. */
39
45
  export declare function assessAnchors(projectRoot: string, state: PolicyState, rebuild?: boolean): AnchorAssessment;
40
46
  /**
41
47
  * Append an entry for the current state if the newest entry does not already
42
48
  * describe it. Must run under the policy lock. Throws if the log is corrupt:
43
49
  * only an explicit establish may replace a corrupt log.
44
50
  */
45
- export declare function recordAnchor(projectRoot: string, state: PolicyState, write: AnchorWriter, event?: AnchorEvent, by?: string): Promise<void>;
51
+ export declare function recordAnchor(projectRoot: string, state: PolicyState, write: AnchorWriter, event?: AnchorEvent, by?: string, writeLog?: StreamingAnchorWriter, assessedTip?: AnchorEntry): Promise<void>;
46
52
  export declare function anchorPosture(projectRoot: string): {
47
53
  log: boolean;
48
54
  mirror: boolean;
@@ -10,11 +10,29 @@
10
10
  * Threat model: this detects any tampering confined to state.json, and any
11
11
  * tampering confined to the project directory. It does NOT stop an attacker
12
12
  * who can rewrite both the project directory and the user's home directory.
13
+ *
14
+ * Archive coverage (#3892): once receipts have been pruned, the default
15
+ * (non-`--full`) verify path checks the archive's TIP only — it confirms
16
+ * the archive's newest entry still matches what the anchor recorded, in
17
+ * bounded time, catching deletion, truncation, and tampering of that tip
18
+ * entry. It does NOT walk `previousReceiptHash` back through the rest of
19
+ * the archive, so a surgical edit to an INTERIOR archived receipt that
20
+ * leaves the tip entry (and everything chained after it) byte-for-byte
21
+ * untouched is NOT detected by default verify. Only
22
+ * `verifyPolicyLedger({ full: true })` (`ruflo policy verify --full`),
23
+ * which replays the entire archive, closes that gap. This is the inherent
24
+ * trade-off of a cheap tip check versus an O(total-history) replay, not an
25
+ * oversight — closing it fully on the default path would reintroduce the
26
+ * unbounded per-transaction cost #3164 removed.
13
27
  */
28
+ import { decodeLedgerRecord, policyLedgerRecordLimit } from './policy-ledger-record.js';
29
+ import { reverseArchiveRecords } from './policy-archive-scan.js';
30
+ import { anchorEntries, appendedAnchorLog } from './policy-anchor-log.js';
14
31
  import { createHash } from 'node:crypto';
15
- import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
16
- import { dirname, isAbsolute, join, resolve } from 'node:path';
17
- import { homedir, userInfo } from 'node:os';
32
+ import { closeSync, constants as fsConstants, fstatSync, existsSync, mkdtempSync, chmodSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { isAbsolute, join, resolve } from 'node:path';
34
+ import { homedir, tmpdir, userInfo } from 'node:os';
35
+ import { AgenticPolicyEngine, canonicalizePolicy } from '@claude-flow/security/policy/index';
18
36
  const LOG_FILE = 'ledger-anchors.json';
19
37
  const MIRROR_FILE = 'ledger-anchor-head.json';
20
38
  export const anchorLogPath = (projectRoot) => join(resolve(projectRoot), '.claude-flow', 'policy', LOG_FILE);
@@ -55,10 +73,214 @@ function mirrorPath(projectRoot) {
55
73
  const id = createHash('sha256').update(realpathSync(projectRoot)).digest('hex');
56
74
  return join(policyTrustRoot(), id, MIRROR_FILE);
57
75
  }
76
+ // Duplicated from policy-runtime.ts's RECEIPT_ARCHIVE_FILE rather than
77
+ // imported, to avoid a circular dependency (policy-runtime.ts imports this
78
+ // module) — same pattern already used by anchorLogPath above.
79
+ const ARCHIVE_FILE = 'receipts.ledger.jsonl';
80
+ function archiveLedgerPath(projectRoot) {
81
+ return join(resolve(projectRoot), '.claude-flow', 'policy', ARCHIVE_FILE);
82
+ }
83
+ const TAIL_READ_INITIAL_BYTES = 64 * 1024;
84
+ /**
85
+ * Whether the archive still contains a self-consistent copy of the receipt
86
+ * at `expectedSequence` whose own stored `hash` is `expectedHash` (#3892
87
+ * Blocker 1) — i.e. the archive's TIP, nothing more. Scans a trailing
88
+ * suffix in fixed 64KiB chunks until a match is found or the
89
+ * whole file has been scanned — NOT just the physical last line: a harmless
90
+ * crash-duplicate (#3164) can legitimately land after the true tail, so
91
+ * truncation/tampering is "the matching receipt is nowhere in the file",
92
+ * not "it isn't the very last line". "Self-consistent" means
93
+ * `policyHash(payload) === hash` — a tamper that edits a receipt's content
94
+ * but leaves its stale `hash` field untouched must not read back as a
95
+ * match.
96
+ *
97
+ * Coverage boundary (disclosed at the top of this file): this checks only
98
+ * the tip record itself, never `previousReceiptHash` back through the rest
99
+ * of the archive. An interior receipt tampered with its OWN hash
100
+ * recomputed to stay self-consistent, while the tip and everything after
101
+ * it are left untouched, passes this check — only `--full` catches that.
102
+ *
103
+ * Cost: bounded on the common (untampered) path, where the match is almost
104
+ * always inside the first 64KB suffix; a truncation/deletion attack (where
105
+ * no match exists anywhere) scans O(archive size) bytes with bounded
106
+ * per-record memory before reporting tampering — an acceptable cost on that rare failure path. On
107
+ * the healthy path this runs on every `withPolicyTransaction` call once
108
+ * `retainedFrom > 0` (via `checkArchiveClaim` → `assessAnchors` →
109
+ * `reconcileLedger`), not only on explicit verify calls.
110
+ * This PR originally measured its un-synced tip reader separately; those
111
+ * timings do not describe the durable writer protocol added here.
112
+ * Crash catch-up checks only the newly archived extension from a trusted
113
+ * head; this rare path costs O(extension plus the trailing archive window).
114
+ */
115
+ function archiveHasTailReceipt(filePath, expectedSequence, expectedHash, from) {
116
+ if (!existsSync(filePath) || !Number.isSafeInteger(expectedSequence) || expectedSequence < 0)
117
+ return false;
118
+ let fd;
119
+ try {
120
+ fd = openSync(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ let scratch;
126
+ try {
127
+ const stat = fstatSync(fd);
128
+ if (!stat.isFile() || stat.size === 0)
129
+ return false;
130
+ if (from && (!Number.isSafeInteger(from.sequence) || from.sequence >= expectedSequence || expectedSequence - from.sequence > stat.size))
131
+ return false;
132
+ const verifier = new AgenticPolicyEngine({ signingKey: process.env.CLAUDE_FLOW_POLICY_SIGNING_KEY, keyId: process.env.CLAUDE_FLOW_POLICY_KEY_ID });
133
+ if (from) {
134
+ scratch = mkdtempSync(join(tmpdir(), 'ruflo-policy-extension-'));
135
+ chmodSync(scratch, 0o700);
136
+ }
137
+ let found;
138
+ let extensionCount = 0;
139
+ for (const physical of reverseArchiveRecords(fd, stat.size, policyLedgerRecordLimit())) {
140
+ let line;
141
+ try {
142
+ line = decodeLedgerRecord(physical.bytes);
143
+ }
144
+ catch (error) {
145
+ // An unacknowledged final append may end inside a UTF8 code point.
146
+ // Preserve it here; the writer can repair only an exact state-backed
147
+ // byte prefix. Newline-complete records must always decode strictly.
148
+ if (!physical.terminated && error.message === 'policy-ledger-archive-utf8-error')
149
+ continue;
150
+ throw error;
151
+ }
152
+ if (!line.trim())
153
+ continue;
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(line);
157
+ }
158
+ catch {
159
+ continue;
160
+ }
161
+ const sequence = parsed?.payload?.sequence;
162
+ if (sequence === expectedSequence) {
163
+ if (verifier.verifyReceipt(parsed) || parsed.hash !== expectedHash)
164
+ return false;
165
+ const canonical = canonicalizePolicy(parsed);
166
+ if (found !== undefined && found !== canonical)
167
+ return false;
168
+ found = canonical;
169
+ }
170
+ if (from && Number.isSafeInteger(sequence) && sequence > from.sequence && sequence <= expectedSequence) {
171
+ if (verifier.verifyReceipt(parsed))
172
+ return false;
173
+ const canonical = canonicalizePolicy(parsed);
174
+ const file = join(scratch, `receipt-${sequence}`);
175
+ try {
176
+ writeFileSync(file, canonical, { mode: 0o600, flag: 'wx' });
177
+ extensionCount++;
178
+ }
179
+ catch (error) {
180
+ if (error.code !== 'EEXIST')
181
+ throw error;
182
+ const other = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
183
+ try {
184
+ if (!fstatSync(other).isFile() || readFileSync(other, 'utf8') !== canonical)
185
+ return false;
186
+ }
187
+ finally {
188
+ closeSync(other);
189
+ }
190
+ }
191
+ }
192
+ // Inspect at least the original healthy 64KiB suffix, including all
193
+ // matching duplicates there, before accepting a found tip.
194
+ if (found !== undefined && physical.offset <= Math.max(0, stat.size - TAIL_READ_INITIAL_BYTES)) {
195
+ if (!from)
196
+ return true;
197
+ if (extensionCount === expectedSequence - from.sequence)
198
+ break;
199
+ }
200
+ }
201
+ if (found === undefined)
202
+ return false;
203
+ if (!from)
204
+ return true;
205
+ if (extensionCount !== expectedSequence - from.sequence)
206
+ return false;
207
+ let previous = from.hash;
208
+ for (let sequence = from.sequence + 1; sequence <= expectedSequence; sequence++) {
209
+ const other = openSync(join(scratch, `receipt-${sequence}`), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
210
+ try {
211
+ if (!fstatSync(other).isFile())
212
+ return false;
213
+ const receipt = JSON.parse(readFileSync(other, 'utf8'));
214
+ if (verifier.verifyReceipt(receipt) || receipt.payload.previousReceiptHash !== previous)
215
+ return false;
216
+ previous = receipt.hash;
217
+ }
218
+ finally {
219
+ closeSync(other);
220
+ }
221
+ }
222
+ return previous === expectedHash;
223
+ }
224
+ catch (error) {
225
+ if (error.message.startsWith('policy-ledger-archive-record-too-large:')
226
+ || error.message === 'policy-ledger-archive-utf8-error'
227
+ || error.message === 'policy-ledger-audit-invalid-record-limit')
228
+ throw error;
229
+ return false;
230
+ }
231
+ finally {
232
+ closeSync(fd);
233
+ if (scratch !== undefined)
234
+ rmSync(scratch, { recursive: true, force: true });
235
+ }
236
+ }
237
+ /** #3164: `state.receipts` is only the hot tail once pruning is in play — the
238
+ * anchor log always speaks in terms of the full historical ledger length. */
239
+ function fullLength(state) {
240
+ return (state.retainedFrom ?? 0) + state.receipts.length;
241
+ }
242
+ /**
243
+ * Hash of the receipt at absolute (historical) 1-based length
244
+ * `absoluteLength`, or `undefined` if it is neither in the hot tail nor
245
+ * exactly the boundary receipt this transaction just pruned (#3164).
246
+ * `recordAnchor` runs every transaction, so the anchor it reads here is
247
+ * always at most one transaction behind the current state — never further
248
+ * back than the just-pruned boundary.
249
+ */
250
+ function receiptHashAt(state, absoluteLength) {
251
+ const retainedFrom = state.retainedFrom ?? 0;
252
+ const index = absoluteLength - 1;
253
+ if (index === retainedFrom - 1)
254
+ return state.prunedHead ?? undefined;
255
+ if (index >= retainedFrom)
256
+ return state.receipts[index - retainedFrom]?.hash;
257
+ return undefined;
258
+ }
259
+ /**
260
+ * The two archive fields are appended to the hashed array ONLY when at
261
+ * least one of them is set on this entry (#3892 Blocker 1). An anchor
262
+ * written before the fields existed, or for a ledger that was never
263
+ * pruned, therefore hashes over the exact same array as before this
264
+ * change — appending `null, null` unconditionally would change the
265
+ * hashed byte string and break every anchor already on disk.
266
+ */
267
+ function anchorHeadMatches(projectRoot, state, anchor) {
268
+ const hotHash = receiptHashAt(state, anchor.length);
269
+ if (hotHash !== undefined)
270
+ return hotHash === anchor.headHash;
271
+ const retainedFrom = state.retainedFrom ?? 0;
272
+ return anchor.length < retainedFrom && typeof anchor.headHash === 'string' && typeof state.prunedHead === 'string'
273
+ && archiveHasTailReceipt(archiveLedgerPath(projectRoot), anchor.length - 1, anchor.headHash)
274
+ && archiveHasTailReceipt(archiveLedgerPath(projectRoot), retainedFrom - 1, state.prunedHead, { sequence: anchor.length - 1, hash: anchor.headHash });
275
+ }
58
276
  function entryHash(entry) {
59
- return createHash('sha256').update(JSON.stringify([
277
+ const fields = [
60
278
  entry.seq, entry.length, entry.headHash, entry.prevAnchorHash, entry.ts, entry.event, entry.by ?? null,
61
- ])).digest('hex');
279
+ ];
280
+ if (entry.archivedThrough !== undefined || entry.archiveHeadHash !== undefined) {
281
+ fields.push(entry.archivedThrough ?? null, entry.archiveHeadHash ?? null);
282
+ }
283
+ return createHash('sha256').update(JSON.stringify(fields)).digest('hex');
62
284
  }
63
285
  function validEntry(value, seq, prev) {
64
286
  const e = value;
@@ -69,24 +291,32 @@ function validEntry(value, seq, prev) {
69
291
  && e.prevAnchorHash === prev
70
292
  && typeof e.ts === 'number'
71
293
  && ['append', 'migrated-from-state', 'establish-anchor'].includes(e.event)
294
+ && (e.archivedThrough === undefined || (Number.isInteger(e.archivedThrough) && e.archivedThrough >= 0))
295
+ && (e.archiveHeadHash === undefined || e.archiveHeadHash === null || typeof e.archiveHeadHash === 'string')
72
296
  && typeof e.hash === 'string'
73
297
  && e.hash === entryHash({ ...e, hash: undefined });
74
298
  }
75
- function readLog(projectRoot) {
299
+ // Identity-only, single-use assessment metadata. Plain JSON cannot grant reuse.
300
+ // Callers hold the policy lock across assessment and publication; the append
301
+ // serializer independently authenticates the entire source and binds count/tip.
302
+ const assessedLogs = new WeakMap();
303
+ function readLog(projectRoot, mirrorHash) {
76
304
  const file = anchorLogPath(projectRoot);
77
305
  if (!existsSync(file))
78
- return [];
306
+ return { count: 0, mirrorIncluded: false };
79
307
  try {
80
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
81
- if (parsed.version !== 1 || !Array.isArray(parsed.entries))
82
- return 'corrupt';
83
308
  let prev = null;
84
- for (let i = 0; i < parsed.entries.length; i++) {
85
- if (!validEntry(parsed.entries[i], i, prev))
309
+ const summary = { count: 0, mirrorIncluded: false };
310
+ for (const value of anchorEntries(file)) {
311
+ if (!validEntry(value, summary.count, prev))
86
312
  return 'corrupt';
87
- prev = parsed.entries[i].hash;
313
+ summary.count++;
314
+ summary.last = value;
315
+ prev = value.hash;
316
+ if (mirrorHash !== undefined && value.hash === mirrorHash)
317
+ summary.mirrorIncluded = true;
88
318
  }
89
- return parsed.entries;
319
+ return summary;
90
320
  }
91
321
  catch {
92
322
  return 'corrupt';
@@ -106,82 +336,168 @@ function readMirror(projectRoot) {
106
336
  return 'corrupt';
107
337
  }
108
338
  }
339
+ /**
340
+ * Cross-checks one anchor's claim about the archive against current state
341
+ * and, when that claim is self-consistent, against the live archive file
342
+ * on disk (#3892 Blocker 1). `undefined` means no mismatch was found —
343
+ * including the legitimate transitional case of an anchor written before
344
+ * archive-anchoring existed, which `behind` catches up separately.
345
+ *
346
+ * Tip check only — see `archiveHasTailReceipt`'s doc comment and the
347
+ * file-level comment for the coverage boundary (an interior-archive
348
+ * tamper that leaves the tip untouched is not caught here) and the
349
+ * measured per-transaction cost this adds once `retainedFrom > 0`.
350
+ */
351
+ function checkArchiveClaim(projectRoot, state, anchor) {
352
+ const retainedFrom = state.retainedFrom ?? 0;
353
+ if (retainedFrom === 0)
354
+ return undefined;
355
+ const archiveHeadHash = state.prunedHead ?? null;
356
+ if (archiveHeadHash === null
357
+ || !archiveHasTailReceipt(archiveLedgerPath(projectRoot), retainedFrom - 1, archiveHeadHash)) {
358
+ return 'policy-ledger-archive-tampered';
359
+ }
360
+ if (anchor.archivedThrough === undefined)
361
+ return undefined;
362
+ if (anchor.archivedThrough > retainedFrom
363
+ || (anchor.archivedThrough === retainedFrom && (anchor.archiveHeadHash ?? null) !== archiveHeadHash)) {
364
+ return 'policy-ledger-anchor-mismatch';
365
+ }
366
+ // State is published before the secondary anchors. A crash can therefore
367
+ // leave an older, durable archive claim. Preserve and authenticate that
368
+ // older tip plus the state's current tip before catching anchors up.
369
+ if (anchor.archivedThrough > 0 && (!anchor.archiveHeadHash
370
+ || !archiveHasTailReceipt(archiveLedgerPath(projectRoot), anchor.archivedThrough - 1, anchor.archiveHeadHash))) {
371
+ return 'policy-ledger-archive-tampered';
372
+ }
373
+ return undefined;
374
+ }
109
375
  /**
110
376
  * How the stored anchors relate to `state`; read-only. `rebuild` is the
111
377
  * explicit-establish path: an unreadable log or mirror is treated as absent, but
112
378
  * whatever readable anchor remains must still agree with the chain, so it can
113
379
  * never bless a truncation the anchors still remember.
114
380
  */
115
- export function assessAnchors(projectRoot, state, rebuild = false) {
116
- let entries = readLog(projectRoot);
117
- if (entries === 'corrupt') {
118
- if (!rebuild)
119
- return { kind: 'fail', error: 'policy-anchor-log-corrupt' };
120
- entries = [];
121
- }
381
+ function assessAnchorsChecked(projectRoot, state, rebuild = false) {
122
382
  let mirror = readMirror(projectRoot);
123
383
  if (mirror === 'corrupt') {
124
384
  if (!rebuild)
125
385
  return { kind: 'fail', error: 'policy-anchor-mirror-corrupt' };
126
386
  mirror = null;
127
387
  }
128
- if (entries.length === 0) {
388
+ let entries = readLog(projectRoot, mirror?.hash);
389
+ if (entries === 'corrupt') {
390
+ if (!rebuild)
391
+ return { kind: 'fail', error: 'policy-anchor-log-corrupt' };
392
+ entries = { count: 0, mirrorIncluded: false };
393
+ }
394
+ const retainedFrom = state.retainedFrom ?? 0;
395
+ if (entries.count === 0) {
129
396
  if (!mirror)
130
397
  return { kind: 'absent' };
131
398
  if (!rebuild)
132
399
  return { kind: 'fail', error: 'policy-anchor-log-missing' };
133
- if (state.receipts.length < mirror.length)
400
+ if (fullLength(state) < mirror.length)
134
401
  return { kind: 'fail', error: 'policy-ledger-truncated' };
135
- if (mirror.length > 0 && state.receipts[mirror.length - 1].hash !== mirror.headHash) {
402
+ if (mirror.length > 0 && !anchorHeadMatches(projectRoot, state, mirror)) {
136
403
  return { kind: 'fail', error: 'policy-ledger-anchor-mismatch' };
137
404
  }
405
+ const archiveError = checkArchiveClaim(projectRoot, state, mirror);
406
+ if (archiveError)
407
+ return { kind: 'fail', error: archiveError };
138
408
  return { kind: 'absent' };
139
409
  }
140
- if (mirror && !entries.some((entry) => entry.hash === mirror.hash)) {
410
+ if (mirror && !entries.mirrorIncluded) {
141
411
  return { kind: 'fail', error: 'policy-anchor-log-rolled-back' };
142
412
  }
143
- const last = entries[entries.length - 1];
413
+ const last = entries.last;
414
+ // A failed best-effort mirror write may leave a genuine earlier entry,
415
+ // including an entry from before the first prune. Membership in the fully
416
+ // validated log above plus the trusted-head/extension checks below grant
417
+ // catch-up authority; asymmetric archive fields alone do not prove tampering.
144
418
  for (const anchor of mirror ? [last, mirror] : [last]) {
145
- if (state.receipts.length < anchor.length)
419
+ if (fullLength(state) < anchor.length)
146
420
  return { kind: 'fail', error: 'policy-ledger-truncated' };
147
- if (anchor.length > 0 && state.receipts[anchor.length - 1].hash !== anchor.headHash) {
421
+ if (anchor.length > 0 && !anchorHeadMatches(projectRoot, state, anchor)) {
148
422
  return { kind: 'fail', error: 'policy-ledger-anchor-mismatch' };
149
423
  }
424
+ const archiveError = checkArchiveClaim(projectRoot, state, anchor);
425
+ if (archiveError)
426
+ return { kind: 'fail', error: archiveError };
427
+ }
428
+ const behind = fullLength(state) > last.length
429
+ || (retainedFrom > 0 && last.archivedThrough === undefined);
430
+ Object.freeze(last);
431
+ assessedLogs.set(last, { file: anchorLogPath(projectRoot), summary: entries });
432
+ return { kind: 'ok', last, behind };
433
+ }
434
+ /** Convert archive record-limit/UTF8 failures into structured gate failures. */
435
+ export function assessAnchors(projectRoot, state, rebuild = false) {
436
+ try {
437
+ return assessAnchorsChecked(projectRoot, state, rebuild);
438
+ }
439
+ catch (error) {
440
+ const message = error.message;
441
+ return { kind: 'fail', error: message.startsWith('policy-ledger-') ? message : 'policy-ledger-archive-read-error' };
150
442
  }
151
- return { kind: 'ok', last, behind: state.receipts.length > last.length };
152
443
  }
153
444
  /**
154
445
  * Append an entry for the current state if the newest entry does not already
155
446
  * describe it. Must run under the policy lock. Throws if the log is corrupt:
156
447
  * only an explicit establish may replace a corrupt log.
157
448
  */
158
- export async function recordAnchor(projectRoot, state, write, event = 'append', by) {
159
- if (state.receipts.length === 0)
449
+ export async function recordAnchor(projectRoot, state, write, event = 'append', by, writeLog, assessedTip) {
450
+ const length = fullLength(state);
451
+ if (length === 0)
160
452
  return;
161
- const loaded = readLog(projectRoot);
162
- const entries = loaded === 'corrupt' ? [] : loaded;
453
+ const cached = assessedTip && assessedLogs.get(assessedTip);
454
+ if (assessedTip)
455
+ assessedLogs.delete(assessedTip);
456
+ // Reuse only for an actual append through the production streaming writer.
457
+ // Unchanged checkpoints still read the live log before refreshing the mirror.
458
+ const stateHead = receiptHashAt(state, length);
459
+ const changed = assessedTip && (assessedTip.length !== length || assessedTip.headHash !== stateHead
460
+ || assessedTip.archivedThrough !== ((state.retainedFrom ?? 0) > 0 ? state.retainedFrom : undefined)
461
+ || (assessedTip.archiveHeadHash ?? undefined) !== ((state.retainedFrom ?? 0) > 0 ? state.prunedHead ?? null : undefined));
462
+ const loaded = writeLog && changed && cached?.file === anchorLogPath(projectRoot)
463
+ ? cached.summary : readLog(projectRoot);
464
+ const entries = loaded === 'corrupt' ? { count: 0, mirrorIncluded: false } : loaded;
163
465
  if (loaded === 'corrupt' && event !== 'establish-anchor')
164
466
  throw new Error('policy-anchor-log-corrupt');
165
- const last = entries[entries.length - 1];
166
- const length = state.receipts.length;
167
- const headHash = state.receipts[length - 1].hash;
467
+ const last = entries.last;
468
+ const headHash = receiptHashAt(state, length);
469
+ if (headHash === undefined)
470
+ throw new Error('policy-ledger-anchor-head-unresolvable');
471
+ const retainedFrom = state.retainedFrom ?? 0;
472
+ const archivedThrough = retainedFrom > 0 ? retainedFrom : undefined;
473
+ const archiveHeadHash = retainedFrom > 0 ? (state.prunedHead ?? null) : undefined;
168
474
  let tip = last;
169
- if (!last || last.length !== length || last.headHash !== headHash) {
475
+ // Deferred archival can advance the archive boundary without a new receipt.
476
+ if (!last || last.length !== length || last.headHash !== headHash
477
+ || last.archivedThrough !== archivedThrough || (last.archiveHeadHash ?? undefined) !== archiveHeadHash) {
170
478
  const base = {
171
- seq: entries.length,
479
+ seq: entries.count,
172
480
  length,
173
481
  headHash,
174
482
  prevAnchorHash: last?.hash ?? null,
175
483
  ts: Date.now(),
176
484
  event,
177
485
  ...(by ? { by } : {}),
486
+ ...(archivedThrough !== undefined ? { archivedThrough, archiveHeadHash } : {}),
178
487
  };
179
488
  tip = { ...base, hash: entryHash(base) };
180
- await write(anchorLogPath(projectRoot), { version: 1, entries: [...entries, tip] });
489
+ if (writeLog) {
490
+ await writeLog(anchorLogPath(projectRoot), appendedAnchorLog(loaded === 'corrupt' || entries.count === 0 ? undefined : anchorLogPath(projectRoot), tip, validEntry));
491
+ }
492
+ else {
493
+ // Preserve the exported custom-writer contract: ordinary version-1 JSON.
494
+ // The production runtime supplies the bounded streaming writer above.
495
+ const history = loaded === 'corrupt' || entries.count === 0 ? [] : [...anchorEntries(anchorLogPath(projectRoot))];
496
+ await write(anchorLogPath(projectRoot), { version: 1, entries: [...history, tip] });
497
+ }
181
498
  }
182
499
  try {
183
500
  const mirror = mirrorPath(projectRoot);
184
- mkdirSync(dirname(mirror), { recursive: true, mode: 0o700 });
185
501
  await write(mirror, tip);
186
502
  }
187
503
  catch (error) {
@@ -0,0 +1,22 @@
1
+ /** Bounded-memory full policy audit. The caller owns the captured archive FD.
2
+ * Only bytes below its locked snapshot boundary are read; normal later appends
3
+ * do not join the audit. Disk scratch is private, disposable, never ledger state.
4
+ */
5
+ import { type LedgerVerification, type PolicyState } from '@claude-flow/security/policy/index';
6
+ export interface ArchiveSnapshot {
7
+ fd: number;
8
+ size: number;
9
+ }
10
+ /** Must run under the policy writer lock. O(1), with no whole-file read. */
11
+ export declare function captureArchiveSnapshot(file: string): ArchiveSnapshot | undefined;
12
+ /** Every physical record is authenticated before deduplication, including
13
+ * unclaimed staging records. The fixed-width index only addresses the bounded
14
+ * snapshot's claimed prefix; arbitrary extra sequence numbers never set sparse
15
+ * file offsets. Exact full canonical equality is checked on duplicate rereads.
16
+ * Scratch I/O is disposable and does not need fsync or mutate the ledger.
17
+ */
18
+ export declare function verifyFullPolicyLedger(snapshot: ArchiveSnapshot | undefined, state: PolicyState, engineOptions: {
19
+ signingKey?: string;
20
+ keyId?: string;
21
+ }): Promise<LedgerVerification>;
22
+ //# sourceMappingURL=policy-ledger-audit.d.ts.map