Summary
Two related receiver-side gaps in peer sync let an initiator overwrite or clobber receiver bytes with no history — breaking the append-only guarantee on the receiving node.
5a — Transfer disposition overwrites an un-indexed receiver file with no history (HIGH)
Where: agent/sync.go (planSession pre-stages copy-from-existing / supersede / conflict, but not Transfer; classifyMissingPath does no disk stat); sync/node.go (invokeRclone builds copy with no --backup-dir for node syncs).
Scenario: A receiver gains a file at path P out-of-band (web app / scp / created since last index), never indexed, so no present row. An initiator holds different content at P and pushes. classify → GetByPath(P) NotFound → Transfer. Transfer is in scope; the initiator's rclone overwrites P and, because the receiver only pre-moves the other three dispositions to .squirrel-history, the out-of-band bytes are destroyed with no preservation.
The prior SAFETY-AUDIT (§C2) waved this off: "--checksum --hash blake3 would catch the divergence as a transfer rather than a no-op." That conflates detecting the divergence with preserving it — deciding to transfer overwrites, and without --backup-dir the prior bytes are gone. The CopyFromExisting branch got the Lstat-and-move-to-history guard; Transfer did not.
Fix: give Transfer the same pre-stage — before the rclone pass, Lstat each Transfer destination; if a file exists with no live index row, move it into .squirrel-history/run-<receiverRunID>/ first (or reclassify to conflict).
5b — Receiver path validation omits two reserved names (MEDIUM)
Where: agent/sync.go — validateRelPath/validateFolderPath reject only .squirrel-history and .squirrel-conflicts; the initiator-side filter (sync/node.go isReservedSyncPath/isReservedFolderPath) correctly covers all four including .squirrel-restore-history and .squirrel-index.
Scenario: A malicious peer sends path = ".squirrel-restore-history/run-1/<existing>". The receiver accepts it (validation gap), classifies Transfer (filtered from indexing, so no row), and the initiator's rclone delivers bytes there. Combined with 5a, this overwrites the receiver's only pre-restore backup with no history.
Fix: add RestoreHistoryDirName and IndexDirName to the reserved-prefix checks in both receiver validators so the allow-list matches the initiator's filter.
Adversarial audit of offload-v1 (auditors A F1/F2, C H-1).
Summary
Two related receiver-side gaps in peer sync let an initiator overwrite or clobber receiver bytes with no history — breaking the append-only guarantee on the receiving node.
5a —
Transferdisposition overwrites an un-indexed receiver file with no history (HIGH)Where:
agent/sync.go(planSessionpre-stages copy-from-existing / supersede / conflict, but notTransfer;classifyMissingPathdoes no disk stat);sync/node.go(invokeRclonebuildscopywith no--backup-dirfor node syncs).Scenario: A receiver gains a file at path
Pout-of-band (web app / scp / created since last index), never indexed, so nopresentrow. An initiator holds different content atPand pushes.classify→GetByPath(P)NotFound →Transfer.Transferis in scope; the initiator's rclone overwritesPand, because the receiver only pre-moves the other three dispositions to.squirrel-history, the out-of-band bytes are destroyed with no preservation.The prior SAFETY-AUDIT (§C2) waved this off: "
--checksum --hash blake3would catch the divergence as a transfer rather than a no-op." That conflates detecting the divergence with preserving it — deciding to transfer overwrites, and without--backup-dirthe prior bytes are gone. TheCopyFromExistingbranch got the Lstat-and-move-to-history guard;Transferdid not.Fix: give
Transferthe same pre-stage — before the rclone pass,LstateachTransferdestination; if a file exists with no live index row, move it into.squirrel-history/run-<receiverRunID>/first (or reclassify to conflict).5b — Receiver path validation omits two reserved names (MEDIUM)
Where:
agent/sync.go—validateRelPath/validateFolderPathreject only.squirrel-historyand.squirrel-conflicts; the initiator-side filter (sync/node.goisReservedSyncPath/isReservedFolderPath) correctly covers all four including.squirrel-restore-historyand.squirrel-index.Scenario: A malicious peer sends
path = ".squirrel-restore-history/run-1/<existing>". The receiver accepts it (validation gap), classifiesTransfer(filtered from indexing, so no row), and the initiator's rclone delivers bytes there. Combined with 5a, this overwrites the receiver's only pre-restore backup with no history.Fix: add
RestoreHistoryDirNameandIndexDirNameto the reserved-prefix checks in both receiver validators so the allow-list matches the initiator's filter.Adversarial audit of offload-v1 (auditors A F1/F2, C H-1).