@claude-flow/cli 3.56.9 → 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.
- package/.claude/helpers/.helpers-version +1 -1
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/intelligence.cjs +51 -9
- package/.claude/helpers/router.cjs +0 -0
- package/.claude/skills/verification-quality/SKILL.md +14 -11
- package/README.md +1 -1
- package/catalog-manifest.json +2 -2
- package/dist/src/autopilot-state.d.ts +2 -0
- package/dist/src/autopilot-state.js +36 -8
- package/dist/src/command-resolution.d.ts +15 -0
- package/dist/src/command-resolution.js +25 -0
- package/dist/src/commands/agent.js +17 -17
- package/dist/src/commands/claims.js +6 -8
- package/dist/src/commands/daemon.js +94 -33
- package/dist/src/commands/doctor.js +95 -18
- package/dist/src/commands/memory.js +14 -2
- package/dist/src/commands/policy.js +6 -1
- package/dist/src/commands/security-code-patterns.d.ts +2 -2
- package/dist/src/commands/security-code-patterns.js +61 -3
- package/dist/src/commands/security.d.ts +24 -0
- package/dist/src/commands/security.js +81 -35
- package/dist/src/commands/tui.d.ts +9 -0
- package/dist/src/commands/tui.js +29 -3
- package/dist/src/config/proven-config-refresh.d.ts +9 -0
- package/dist/src/config/proven-config-refresh.js +24 -3
- package/dist/src/funnel/local-signals.js +2 -1
- package/dist/src/index.js +11 -47
- package/dist/src/init/settings-generator.js +4 -58
- package/dist/src/init/types.js +1 -1
- package/dist/src/mcp-tools/agent-execute-core.d.ts +2 -0
- package/dist/src/mcp-tools/agent-execute-core.js +15 -0
- package/dist/src/mcp-tools/agent-health-metrics.d.ts +22 -0
- package/dist/src/mcp-tools/agent-health-metrics.js +33 -0
- package/dist/src/mcp-tools/agent-tools.js +45 -22
- package/dist/src/mcp-tools/agentbbs-federation.d.ts +1 -1
- package/dist/src/mcp-tools/agentbbs-federation.js +7 -2
- package/dist/src/mcp-tools/agentdb-tools.js +25 -2
- package/dist/src/mcp-tools/aidefence-known-misses.d.ts +22 -0
- package/dist/src/mcp-tools/aidefence-known-misses.js +57 -0
- package/dist/src/mcp-tools/analyze-tools.js +10 -11
- package/dist/src/mcp-tools/autopilot-tools.js +2 -2
- package/dist/src/mcp-tools/browser-intent-tools.js +6 -2
- package/dist/src/mcp-tools/embeddings-tools.js +29 -7
- package/dist/src/mcp-tools/hooks-tools.js +4 -0
- package/dist/src/mcp-tools/memory-tools.d.ts +1 -1
- package/dist/src/mcp-tools/memory-tools.js +100 -25
- package/dist/src/mcp-tools/metaharness-tools.js +1 -1
- package/dist/src/mcp-tools/neural-tools.js +48 -54
- package/dist/src/mcp-tools/policy-tools.js +5 -3
- package/dist/src/mcp-tools/ruvllm-tools.js +6 -0
- package/dist/src/mcp-tools/security-tools.js +55 -17
- package/dist/src/mcp-tools/workflow-tools.js +66 -17
- package/dist/src/memory/intelligence.d.ts +5 -1
- package/dist/src/memory/intelligence.js +158 -35
- package/dist/src/memory/memory-bridge.js +10 -3
- package/dist/src/memory/memory-initializer.js +2 -1
- package/dist/src/memory/search-defaults.d.ts +54 -0
- package/dist/src/memory/search-defaults.js +37 -0
- package/dist/src/memory/sona-optimizer.d.ts +28 -0
- package/dist/src/memory/sona-optimizer.js +59 -2
- package/dist/src/memory/stats-file-lock.d.ts +7 -0
- package/dist/src/memory/stats-file-lock.js +53 -0
- package/dist/src/mods/command-registry/schema.d.ts +24 -24
- package/dist/src/ruvector/agent-wasm.d.ts +10 -0
- package/dist/src/ruvector/agent-wasm.js +27 -1
- package/dist/src/ruvector/diff-classifier.d.ts +2 -4
- package/dist/src/ruvector/diff-classifier.js +202 -69
- package/dist/src/ruvector/ruvllm-wasm.d.ts +4 -0
- package/dist/src/ruvector/ruvllm-wasm.js +12 -1
- package/dist/src/services/distill-oracle.d.ts +1 -1
- package/dist/src/services/distill-oracle.js +2 -2
- package/dist/src/services/policy-anchor-log.d.ts +13 -0
- package/dist/src/services/policy-anchor-log.js +242 -0
- package/dist/src/services/policy-archive-scan.d.ts +7 -0
- package/dist/src/services/policy-archive-scan.js +50 -0
- package/dist/src/services/policy-ledger-anchor.d.ts +14 -8
- package/dist/src/services/policy-ledger-anchor.js +356 -40
- package/dist/src/services/policy-ledger-audit.d.ts +22 -0
- package/dist/src/services/policy-ledger-audit.js +260 -0
- package/dist/src/services/policy-ledger-record.d.ts +3 -0
- package/dist/src/services/policy-ledger-record.js +22 -0
- package/dist/src/services/policy-runtime.d.ts +2 -1
- package/dist/src/services/policy-runtime.js +267 -40
- package/dist/src/services/worker-daemon.d.ts +11 -0
- package/dist/src/services/worker-daemon.js +37 -0
- package/dist/src/transfer/store/discovery.js +16 -17
- package/dist/src/transfer/store/types.d.ts +4 -0
- package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts +17 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/engine.js +99 -24
- package/node_modules/@claude-flow/security/dist/policy/engine.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts +14 -1
- package/node_modules/@claude-flow/security/dist/policy/types.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/policy/types.js +3 -1
- package/node_modules/@claude-flow/security/dist/policy/types.js.map +1 -1
- package/package.json +5 -5
- package/plugins/ruflo-console/dist/tui.mjs +162 -161
- package/plugins/ruflo-metaharness/hooks/screen.ts +7 -1
- package/plugins/ruflo-metaharness/scripts/_darwin.mjs +4 -4
|
@@ -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,
|
|
16
|
-
import {
|
|
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
|
-
|
|
277
|
+
const fields = [
|
|
60
278
|
entry.seq, entry.length, entry.headHash, entry.prevAnchorHash, entry.ts, entry.event, entry.by ?? null,
|
|
61
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
400
|
+
if (fullLength(state) < mirror.length)
|
|
134
401
|
return { kind: 'fail', error: 'policy-ledger-truncated' };
|
|
135
|
-
if (mirror.length > 0 && state
|
|
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.
|
|
410
|
+
if (mirror && !entries.mirrorIncluded) {
|
|
141
411
|
return { kind: 'fail', error: 'policy-anchor-log-rolled-back' };
|
|
142
412
|
}
|
|
143
|
-
const last = entries
|
|
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
|
|
419
|
+
if (fullLength(state) < anchor.length)
|
|
146
420
|
return { kind: 'fail', error: 'policy-ledger-truncated' };
|
|
147
|
-
if (anchor.length > 0 && state
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
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
|
|
166
|
-
const
|
|
167
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|