Third, independent recovery layer for keep-mode upgrades (Part of #791). PR1's manifest keeps the sync out of operator paths and the ephemeral /tmp snapshot rolls back a mid-write crash; this adds the case neither covers — a "successful" upgrade that a manifest/logic bug silently let touch an operator file. - install.sh make_durable_snapshot(): before any mutation, copy the operator-owned surface that exists to $XDG_STATE_HOME/mosaic/backups/pre-update-<UTC-ts>/ (0700 dirs / 0600 files, umask 077 scoped + restored). Fail-open; retention MOSAIC_BACKUP_RETENTION (default 5). - install.sh verify_operator_surface(): post-sync, restore any operator file the upgrade diverged/removed and warn loudly (a divergence = manifest bug). Honors intentional migration removals; refuses to restore through a symlink (CWE-59); guards mkdir under set -e. - mosaic restore (TS): --list (default dry-run), --from <ts> confirmation-gated restore. Strict timestamp selector (CWE-22), O_NOFOLLOW 0600 writes (CWE-59), containment + managed-dir checks via secure-file.ts. Counts/paths only — never emits file contents (secrev). - Gates: typecheck/lint/format ✓; mosaic vitest 1252; restore.spec 30; bash gates durable-snapshot 41 / manifest-guard 193 / rollback 28 / migration 21. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 KiB
Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
Lane: web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
Worktree: /home/hermes/agent-work/stack-agents-dir-791, branch feat/791-upgrade-config-protection
off origin/main 9745bc3f (verified exact head).
Mission prompt (verbatim intent)
Protect operator-owned config under ~/.config/mosaic from framework-upgrade wipes. Ratified
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
Session 1 (2026-07-16) — Phase 1 design
Evidence gathered (wipe mechanism, file/line)
mosaic update→update-checker.ts:509buildReseedCommand→bash install.sh(MOSAIC_SYNC_ONLY=1,MOSAIC_INSTALL_MODE=keep).- Wipe =
packages/mosaic/framework/install.sh:199rsync -a --delete+PRESERVE_PATHSdenylist (install.sh:47). cp-fallbackinstall.sh:223find ... -exec rm -rf. - Denylist gaps → WIPED:
agents/*.conf,policy/*.md,*.local.md, harvester/SOP,tools/_lib/credentials.json. - Stale comment
update-checker.ts:492claims*.localpreserved — PRESERVE_PATHS has no such entry. - TS path
file-adapter.ts:157→file-ops.ts:66syncDirectory= non-destructive copy-overlay, BUT its preserve list (file-adapter.ts:164) already DRIFTED from install.sh (missingfleet/backlog,fleet/roles.local). Evidence for single shared manifest SSOT. - Existing snapshot (
install.sh:76) = /tmp, crash-trap only, deleted on success → inadequate; nomosaic restore. fleet-reconciler.ts:93,234already hasregenerate-projections-from-rosterphase separate from lifecycle →mosaic fleet regen= thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
Design decisions
- (b) Invert to allow-list: shared
framework/framework-manifest.json(framework globs + operatorReserved carve-outs); resolve per-path, deny-wins; UNKNOWN ⇒ operator (fail-safe). Mechanism: drop--delete; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests. - (a) Snapshot to
~/.local/state/mosaic/backups/pre-update-<ts>/0700/0600, retention N=5, post-sync verify+restore,mosaic restore --list/--from. No secret values in output. - (d)
mosaic fleet regenprojection-only, preview-first, never restart. - HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
- PR split: PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3 regen+docs. PR2/PR3 depend on PR1.
Status
Design doc written: docs/design/791-upgrade-config-protection.md. Sent to MS-LEAD.
Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
All 4 asks approved. Binding conditions:
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located
*.spec.ts; never--no-verify. - HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
- Manifest-completeness test (§6.2) required.
- Bash+TS read ONE shared
framework-manifest.json; parity test (§6.1) required (closes #631 drift class). - UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
fleet regen: NEVER restart; dry-run default,--writeto apply; "never issues restart" test mandatory.- Independent review every PR; PR2 dedicated secrev.
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
Deviation noted to MS-LEAD in PR: manifest is framework-manifest.txt (line-oriented), NOT .json.
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
tested" requirement is honored — manifest-parity.spec.ts drives the bash resolver as a subprocess
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
PR1 artifacts
- SSOT:
packages/mosaic/framework/framework-manifest.txt([framework]/[operator], deny-wins, fail-safe). - TS resolver:
src/framework/manifest.ts(pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/ planPrune) +manifest.spec.ts(18 tests incl. planPrune property test + §6.2 completeness). - Bash resolver:
framework/tools/_lib/manifest.sh(compiled globs → fork-freemanifest_is_framework; CLIresolve|subtree-roots|classify). Sourced by install.sh. - HARD GATE (§2.4):
framework/tools/quality/scripts/test-upgrade-manifest-guard.sh— keep-mode reseed, 10 operator sentinels (incl. unanticipatedunknown-operator-dir/x,harvester/sop.md,fleet/my-fleet.yaml) survive byte-identical + mtime-unchanged; retired framework file pruned; secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed). - install.sh: keep mode now manifest-driven (
sync_framework_keep, no--delete); overwrite unchanged. PRESERVE_PATHS denylist deleted. - TS sync:
file-ops.syncDirectorygainsisOperatorOwnedguard;file-adapter.syncFrameworkderives it fromloadManifest— hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest. - Parity:
manifest-parity.spec.ts(§6.1) — bash↔TS agree on 34 paths + subtree roots. - Migration matrix
test-install-migration.sh: F6 flipped —my-fleet.yamlnow MUST survive (fail-safe). - CI: new merge-blocking
upgrade-guardstep (.woodpecker/ci.yml) runs both bash suites (adds rsync). - update-checker.ts reseed comment corrected to the manifest model.
Gates (all green)
pnpm typecheck✓ ·pnpm lint✓ ·pnpm format:check✓- Full mosaic vitest: 1062 passed (cli-smoke needs
pnpm buildfirst — build-artifact dep, not this change). - HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
PR opened + reported (2026-07-16)
- PR #802 #802 — base
main@9745bc3f, head34e55d4a(commitfeat(mosaic): manifest-owned upgrade guard…). 15 files, +1160/-142. - Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
Standing by for the independent-review commission at head
34e55d4a. - TWO items flagged to MS-LEAD for decision (awaiting reply):
- Deviation
.txtvs.json— confirm accept (parity-tested) or convert to.json+jq. pr-create -i 791appendedFixes #791→ would auto-close the tracking issue on PR1 merge while PR2/PR3 remain. Recommended edit toPart of #791; awaiting go-ahead to patch PR body.
- Deviation
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
MS-LEAD ruled both my decisions: (1) .txt format ACCEPTED (parity must be strict/merge-blocking incl.
format edge cases + negative probe); (2) trailer Fixes #791→Part of #791 APPROVED (patched PR #802
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
(spec ~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md): #797 Runtime Session Ledger
must survive upgrade. Two coupled deliverables landed in PR1:
- (i) Carve-out:
fleet/run/**was ALREADY an explicit[operator]entry — glob matches the spec's pinnedfleet/run/**EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment to name the ledger (fleet/run/sessions/events.ndjson + ledger.json) so it is unmistakably load-bearing. - (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
- dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly. HARD GATE now 58/58 (was 48).
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to
fleet/**) is isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUTfleet/run/**operator entry + hypotheticalfleet/**framework → ledger resolves framework and planPrune DELETES it (RED); WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18). - Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
· migration 21/21. Committing FORWARD on the branch (NOT rebasing
34e55d4aout from under review).
MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16)
MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from the commissioned independent review). Fixed forward on the branch, red-first:
- B1 (CRITICAL) — dead ERR trap. install.sh had
set -euo pipefail(no-E), so thetrap restore_snapshot ERRnever fired for a failure inside sync_framework_keep() (function body) — a mid-sync abort left a half-written target with NO rollback. Fix:set -Eeuo pipefail(errtrace) + disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gatetest-upgrade-rollback.sh: injects a mid-synccpEACCES (read-only divergent framework file); Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to pre-upgrade); Part B control strips-Eand asserts the rollback message does NOT fire (dead trap) — self-verifying red→green. 7/7. - B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence. Pre-fix: TS
parseManifest('')returned{framework:[],operator:[]}(NO throw) → silent no-op "Installation complete"; bash aborted fragilely (the_manifest_compile"${MANIFEST_OPERATOR[@]:-}"artifact returned 1 with no message) AND the CLI dispatch swallowed manifest_load's rc (no|| exit) soresolveexited 0 resolving everything operator. Fix (fail-loud + identical both langs):- TS
parseManifest: throw on zero framework entries;loadManifest: wrap read error → "Cannot read framework manifest …". - bash
manifest_load: explicit unreadable guard ([[ ! -r ]]) + zero-[framework]guard, both loud stderr + return 1;_manifest_compilegets explicitreturn 0(kills the empty-array artifact); CLI dispatchmanifest_load … || exit 1. finalize.ts: wrap syncFramework →spin.stop('Framework sync aborted …')+ rethrow (never falls through to "Installation complete"). Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing); manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/ empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr); HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero, manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green.
- TS
- Non-blocking addressed. MEDIUM install.sh:222 find-empty now warns on a real failure instead of
blanket
|| true. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs -absent runs prove rsync-independence)..pre-constitution.baktriage: single-shot backup is intentional (reconcile_framework_files backs up once), no change. - Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE 118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16)
Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first:
- Blocker-A (CRITICAL) — signal trap resumed instead of terminating. A bash INT/TERM handler that
merely
restore_snapshot(returns) does NOT terminate the script — execution RESUMES past the interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix:trap 'restore_snapshot; exit 1' ERR INT TERMso both the errtrace (ERR) and signal (INT/TERM) paths exit non-zero. Rollback test Part C: acpshim thatkill -TERM $PPIDmid-sync then succeeds (so set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does NOT print "file phase complete"; control stripsexit 1and asserts the buggy resume-to-success. - Blocker-B (CRITICAL) — degenerate
[framework]section resolved everything operator. A manifest whose framework entries are all empty / bare-dot (/,./,.,..) passed the non-empty guard yet yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolvedoperator, exit 0). Fix (both langs, parity): reject when no entry has a char other than//.— TSisUsableFrameworkGlob=/[^/.]/.test(normalizeRel(glob)), throwsManifestError; bash mirror loops[[ "$(_manifest_norm "$_g")" =~ [^/.] ]], loud stderr + return 1. Tests: manifest.spec.tsit.each(['/','./','.','..','/\n./'])throw; parity +3expectBothReject(root-slash/dot-slash/ bare-dot). RED: reverting the guard makes[framework]\n/resolveoperatorexit 0. - Should-fix-C — misleading abort message. finalize.ts printed one generic "may be partially
applied" for every sync failure. A
ManifestErroris a PRE-sync validation abort (manifest is validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects recovery. Fix: introduceManifestError(exported from manifest.ts, thrown by every fail-closed parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other → "may be partially applied". New co-locatedfinalize-sync-abort.spec.ts(3 tests) asserts both branches re-throw the original error + the correct message, and that config writes are never reached. RED proven by collapsing the classification → the ManifestError test fails. - Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort; manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21.
Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16)
Re-ran codex again; it found two more rollback-path gaps set -E cannot catch. Fixed forward, red-first:
- Blocker-D1 (CRITICAL) —
findscan failures swallowed by process substitution. Both the overlay copy and the scoped prune consumed< <(find … -print0). Bash does NOT propagate the producer's exit status to thewhile, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix:_scan_or_dierunsfind … -print0 > "$tmp"to completion, checks its status, and returns non-zero (→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test Part D: afindshim that fails every-print0scan → shipped installer aborts non-zero + restores + emits "Could not enumerate framework files" + target byte-identical; control neuters the# D1-GUARDreturn 1→ find failure swallowed, upgrade wrongly reports "file phase complete", no rollback. - Blocker-D2 (CRITICAL) — silent
set -eexit on a failed target reset. restore_snapshot did a barerm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"(trap disarmed, under set -e). Ifrm/mkdirfails — possibly afterrmdeleted part of the target — the script exits immediately, skipping the cp AND the recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate. Fix:if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at: $SNAPSHOT_DIR — copy it back …"; return 1; fi(tested like the cp -a check; snapshot NOT deleted). Rollback test Part E: cp-poison triggers restore + anrmshim failsrm -rf <TARGET>→ shipped emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail. - Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 · rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines. No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev)
PR1 (#802) squash-merged → main 32a0ffba; issue #791 stays open (3-PR DAG umbrella). Independent Opus
adversarial/security review APPROVED at head af627e75 (Gitea RoR cmt 17892); lead ran rollback 28/28 +
HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED.
PR2 branch: feat/791-pr2-snapshot-restore off origin/main 32a0ffba. Same treatment applies:
tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs
the queue guard/merge. Report PR2 number + exact head when ready. PR body: Part of #791 (NOT Fixes).
PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate)
- (a) Durable pre-update snapshot to
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/— OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod). Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken BEFORE any mutation. Retention N=5 (MOSAIC_BACKUP_RETENTION), prune older. - Post-sync verify + selective restore: diff operator surface vs snapshot; (b) should never touch operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss.
mosaic restore(TS CLI):--list(default, dry-run) enumerates snapshots by ts;--from <ts>restores over operator surface, confirmation-gated. Counts/paths only.- Secret-safety (secrev): snapshot/restore NEVER emit file contents; only paths/counts. Tests assert 0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output.
PR2 implementation status (2026-07-16, ready-for-review)
All three tasks implemented, red-first proven, unit-green:
- Task #10 — durable snapshot (install.sh):
backup_root()/enumerate_operator_files()/prune_durable_snapshots()/make_durable_snapshot()wired into keep-mode main() aftermanifest_load, before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup failure never aborts the upgrade it protects). RetentionMOSAIC_BACKUP_RETENTION(default 5), in-placesort -r -oprune (nomv— stays inside the rsync-absent coreutils whitelist). - Task #11 — post-sync verify net (install.sh):
verify_operator_surface()runs after sync (trap disarmed),cmp -seach snapshot file vs target; restores any diverged/missing operator file + warns loudly (a divergence = manifest bug). VERIFY-NET wired beforecleanup_snapshot. - Task #12 —
mosaic restore(TS):src/commands/restore.ts+ co-located spec (19 tests).--listdefault (dry-run enumerate),--from <ts>confirmation-gated restore,--dry-run,--yes/MOSAIC_ASSUME_YES. Injectableconfirmfor testability (proceed/decline/env-bypass covered). Restored files forced 0600. Registered incli.ts. Path convention mirrors install.shbackup_root(). - CI:
.woodpecker/ci.ymlupgrade-guard runs the newtest-upgrade-durable-snapshot.shgate. - Gates green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) · durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21. Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered).
- Regression fixed: PR2's
date/sort/mvbroke the rsync-absent manifest-guard PATH whitelist → made date/sort fail-open, replacedmvwith in-placesort -o, addeddate sortto the test whitelist- isolated
XDG_STATE_HOME. All 193 manifest-guard assertions green under restricted PATH.
- isolated
- Codex code-review + security-review (secrev) run on the uncommitted diff before commit.
PR2 review round 1 — findings + remediations (2026-07-16, pre-PR)
Codex code-review returned request-changes (1 blocker + 3 should-fix); Codex security-review returned high (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with a red-first regression test whose control neuters exactly the guard under test:
- A · BLOCKER — verify net undid the legacy bin/ migration (install.sh). On a pre-v2 install
bin/**is operator-classified, so the durable snapshot captured it;run_migrations()deletes bin/ on purpose, butverify_operator_surface()then saw it "missing" and healed it back — the migration would be silently undone forever once the version stamps. Fix:MIGRATION_REMOVED_PATHS[]recorded by run_migrations (bin,rails) +is_migration_removed()skip in the verify loop (# MIGRATION-SKIP-GUARD). Test: Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped) wrongly restores bin/tool.sh. - B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts). An
attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot
would make
cp/copyFileSyncwrite the snapshot's secret out through the link. Fix (bash): refuse a symlinked ancestor (has_symlinked_parent), drop a symlinked leaf before restore (# SYMLINK-LEAF-GUARD). Fix (TS): reuse auditedsecure-file.ts—assertCanonicalContainmentensureManagedDirectoryon every dst, open the leafO_NOFOLLOW|O_CREAT|O_TRUNC0600 (ELOOP = fail-closed). Tests: Part 7 (shipped leaves external exfil target untouched, restores a real 0600 file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first).
- C · MEDIUM/should-fix (CWE-22) —
--fromtraversal escaped the backup root (restore.ts).join(root, from)accepted../poison. Fix: validate the selector against^\d{8}T\d{6}Z(?:-\d+)?$, build exactlyjoin(root,'pre-update-'+ts),lstat(reject symlinked snap dir). Test: restore.specit.eachof 6 malformed selectors +--from ../poisonfail-closed (red-first). - D · should-fix — verify
mkdir -punguarded under set -e (install.sh). A parent replaced by a regular file aborted the installer before the recovery pointer printed. Fix: guardmkdir -p, warncontinueon failure (keeps healing remaining files).
- E · should-fix — snapshot
umask 077leaked process-global (install.sh). Later sync copies/dirs inherited 0600/0700. Fix: saveold_umask, restore on EVERY return path (# UMASK-RESTORE-NORMAL). Test: Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore stripped) makes the synced file 0600.
Full gate suite re-run after fixes (all green): typecheck ✓ · lint ✓ · format:check ✓ · full mosaic
vitest 1252 · restore.spec 30 · durable-snapshot 41 · manifest-guard 193 · rollback 28 ·
migration 21. shellcheck clean on all new lines; new test markers mirror the existing # VERIFY-NET
anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent
(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges.