Skip to content

feat(backup): restore into live state, and redact what leaves the host - #2764

Merged
bolichen97 merged 1 commit into
mainfrom
feat/backup-memory
Aug 28, 2026
Merged

feat(backup): restore into live state, and redact what leaves the host#2764
bolichen97 merged 1 commit into
mainfrom
feat/backup-memory

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

1. What is the problem?

A Kiro Crew operator's memory, lessons, and session history live on one machine. Losing
that machine loses them. Two pieces are needed to make a backup actually useful, and
neither existed:

Restoring is not the same as downloading. Having an archive is not having your agent
back. Live state has to be replaced or merged with the gateway stopped, databases have to
be staged rather than copied over an open handle, saved symlinks have to be reinstated as
links, and a failure partway through must leave the machine as it was rather than half
restored. Nothing did that.

A backup that leaves the host carries every secret it holds. A snapshot is a faithful
copy, which is what makes it a good backup and a bad thing to put in object storage. The
AWS Control app now creates a private drive bucket and pushes to it, but a private bucket
still holds whatever was put into it, and a bundle restored elsewhere carries the original's
tokens verbatim. Nothing rewrote the bytes on their way out.

Scope note: this PR used to include the destination, its hardening, the consent grant, the
transport, session backups and the schedule. PR #5517 (AWS Control) shipped all of that, so
those parts were deleted here rather than merged twice.

2. Why this issue matters to the user

Without restore, a backup is a file you cannot use on the day you need it -- and the day you
need it is the day the machine is gone, which is the worst time to discover the archive only
half applies.

Without outbound redaction, turning on off-host backup silently ships your credentials. The
push path exists now, so this is live exposure rather than a hypothetical: whatever is in
the memory store leaves the machine on a nightly schedule.

3. How our fix solves it

Restore into live state. kirocrew restore takes a local archive and applies it with
--mode replace or --mode merge. Replace is two-phase: every declared tree is backed up
into a rollback directory BEFORE any is removed, so a failure on the third tree cannot leave
the first two gone. The rollback ledger records per file inside the copy loop rather than
from the declared set -- an earlier version recorded the intent, and a failure then printed
"Previous state restored." with an empty failure list while four config files were actually
deleted. Databases are only replaced when an existing regular file is there to replace, a
saved symlink is reinstated as a link before any branch that would dereference it, and
PinnedPathRefusal is caught in both rollback handlers because it subclasses Exception,
not OSError. An s3:// argument is refused rather than treated as a filename, pointing
the operator at the app that owns fetching.

Redact what leaves. snapshot_redact.py rewrites the outbound copy through the repo's
credential and exfiltration-URL scanners. The design constraint is that it must never
degrade into shipping unredacted: a pass that cannot complete raises, and the caller treats
that as a refusal to send. Content that cannot be proven safe is refused rather than
scrubbed-and-hoped: bytes that are not text-shaped, a database that fails its integrity
check, a text container that declares its own extents (startxref, content-length: --
one rule covering PDF, WARC, HTTP archives, MIME multipart and mbox rather than a per-format
list), and a database whose triggers keep reintroducing values the scan just removed.
Databases are rewritten value-by-value through SQL and then rebuilt, so no old value
survives in page slack. Row reads are paged, text is capped at 64 MiB before being read, and
the local archive is never touched -- it lives on the machine that already holds these
secrets, and rewriting it would damage the only copy that restores complete.

Wired into the push, in the order that makes it a control. prepare_redacted_copy is a
destination-free seam: it takes a finished tarball and returns a redacted copy, or None
when the operator has not opted in. The app's run_snapshot_backup calls it BEFORE
authorizing the upload and before handing bytes to the transport, so a redaction that raises
stops the push. Reversing those two steps would leave the guarantee intact on paper while
sending the secrets anyway, which is why the app module is registered as its own redaction
sink in the security posture rather than left implicit.

The switch is beyond the agent's reach. Redaction is opt-in via
<data home>/backup/redaction.json, and that directory is classified sensitive for reading
as well as writing. Flipping it off is the attack; reading it tells an attacker whether the
store is currently being scrubbed. The DIRECTORY is classified, not just the leaf, because a
writable container is the same hole one level up -- replace it with a symlink and the
protected leaf resolves somewhere unprotected.

4. What tests we did

673 tests across the snapshot, redaction, staging and posture suites; all hard gates green
(subprocess-encoding, black, isort, flake8, mypy over 1127 files).

Findings are mutation-verified rather than assumed. Two worth naming because both were
mine:

  • The redaction tests were repointed onto the new seam when _upload_bundle was deleted.
    To prove that rewrite was not vacuous, prepare_redacted_copy was mutated to hand back
    the unredacted original: 16 of 21 tests turned red. They detect unredacted egress.
  • A structural ratchet asserted "every archive reader applies the member bound" by counting
    occurrences of the function name -- which counted its own def line, so a threshold of
    four passed with three real callers. It now asserts the call is present inside each reader
    by name, so a new reader that skips the bound has to be added here to stay green.

Two defects were found by verification rather than by the tests passing:

  • Removing the now-obsolete destination record from the sensitive-path list also un-fenced
    backup/redaction.json, which lives in the same directory. Six tests caught it. The entry
    is restored with a rationale describing its live occupant instead of the deleted one.
  • test_a_database_that_never_settles_is_refused did not fail, it HUNG, which is why it
    read as a suite timeout. The paged row reader advanced on handle > last and its comment
    asserted an UPDATE never changes the handle -- but the trigger under test INSERTS, so new
    rows kept appearing above the cursor and a single pass never terminated. The fixpoint cap
    counts passes, so it never got to fire. Each pass is now bounded by the maximum handle at
    its start; rows that appear mid-pass belong to the next one, which reports them, so a
    trigger that keeps reintroducing values is refused by the pass cap instead of hanging.

5. Any other suggestions on the work

No scope question after all -- I withdrew one. An earlier version of this description
offered to split security.py out, calling it +88/-68 of new shell/parameter scanner
logic. That was a misreading: those regexes exist on the merge base and the diff only
reformatted them. First Principles caught it. The file is now reverted to base with only
the one substantive change re-applied on top -- +15/-0, the "backup" sensitive-path entry
that fences the redaction switch, which belongs in this PR. The .github/black-baseline.txt
line went with the reformat and that file is out of the diff entirely.

Still unverified end to end. The redaction hook and the restore path have not been
exercised against a real push. The transport is the app's now, so what remains to verify
live is this PR's two halves rather than bucket provisioning.

A gap the posture registry does not model. The app's backup module is an egress boundary
that DELEGATES redaction to a registered sink. The registry has a slot for "runs a scanner"
and an allowlist named for non-egress modules, but none for "egress boundary that delegates",
so the row states the delegation in prose. Worth a shape the registry can check.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 11, 2026 05:56
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 1655efe0870ca1b39de560a44d4406ebcd1ae8a2 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound seams and fail-closed ordering, but the redaction opt-in composes into "no off-host backup at all" on any realistic store, with no in-product remedy.

Watch

  • Opting in likely disables the scheduled push entirely. redact_bundle_for_egress refuses the whole upload on any non-text file ("Genuinely not text … the UPLOAD is refused"), and the workspace component stages workspace/ wholesale — one PNG or PDF trips OpaqueFilesPresent on every nightly run. The refusal's own remedy ("Narrow the selection with --components") is unreachable on the only egress path: run_snapshot_backup hardcodes snapshot_main([tmp, "--keep", "1"]) with all components. Net effect: the operator who most wants redaction gets zero off-host copies — the exact "nothing to restore on the day the machine is gone" harm this PR opens with — surfaced only as recurring backup_failed run records.
  • 33 test_snapshot_cycleN*_fixes.py files named by review round, not behavior. Same task-log failure AGENTS.md bans in comments, at file granularity: nobody can locate where symlink or rollback behavior is pinned, so the 34th file gets added instead. ~7k lines of the PR's durable test surface are organized by when they were written.

Suggestions

  • Give the scheduled push a component selection (or an explicit second opt-in to drop-and-report opaque files) so a redaction refusal has an actionable fix besides turning the control off.
  • Fold the cycle test files into behavior-named suites (redaction, rollback, staging, archive-bounds) before they calcify.

[DESIGN-REVIEWED] 1655efe

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @chenmingwei23 overrides the GPT 5.6 finding for 1655efe0870ca1b39de560a44d4406ebcd1ae8a2; the recorded reason is authoritative for this commit.

This comment is updated in place on each push.

The model was not re-run because an authorized human decision supersedes it.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 1655efe0870ca1b39de560a44d4406ebcd1ae8a2: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 1655efe0870ca1b39de560a44d4406ebcd1ae8a2 — this comment is updated in place on each push.

Review details

Based on my independent re-derivation from the code:

The candidate's core chain holds — I confirmed each leg by opening the code:

  • memory.db is stored at the bundle root (snap / "memory.db", line 3354/1678), so a rejected entry has parts == (<root>, "memory.db") and parts[1] == "memory.db", which is not in cleared_trees (snapshot.py:136,143) → not recorded → rejected_entries stays empty → the refusal at snapshot.py:3592 never fires.
  • _component_payload_absent(snap, "memory") returns False on the strength of the real workspace/memory tree (snapshot.py:2975-2977), so the hollow refusal (3724) is skipped.
  • _refuse_corrupt_source_databases continues on the absent memory.db (snapshot.py:2558-2559), so no refusal there.
  • _backup_and_copy skips the absent source (if not pinned_fs.is_regular_at(src_fd, f): continue, snapshot.py:2281), and the live memory.db is not deleted (skip happens before any move).
  • Integrity block at 3909 is gated on (mc / "memory.db").is_file(); absent → skipped, so no warning at all. Restore exits 0.

I discard the candidate's claim that sqlite3.connect "CREATES an empty database" and prints integrity: OK — line 3909 gates that block on is_file(), so it is skipped entirely, not run. The observable harm without it is still: a declared memory restore completes and reports success with the memory database silently absent.

Classification: this is a hole in a new guard (incomplete restore of a declared component reported as success), triggered by a crafted-but-untrusted bundle. It is not live data loss (the live memory.db is skipped, not deleted) nor a crash/corruption/removed-guard, so it is advisory, not blocking.

A crafted bundle that declares memory and carries a real workspace/memory tree but a symlink memory.db restores in replace mode reporting success with the memory database silently missing.

FINDING — snapshot.py:143 — parts[1] in cleared_trees only records rejections inside cleared trees, so a dropped declared core payload file (memory.db, at the bundle root) is never added to rejected_entries; it slips past the hollow check via the present workspace/memory tree and past _refuse_corrupt_source_databases (absent → continue), and replace exits 0 with memory.db never installed → Fix: in _rejection_recording_filter._f, also rejected.append(...) when the dropped entry's path is a declared core payload file (e.g. PurePosixPath(info.name).name in CORE_FILES_FLAT), so the existing rejected_entries refusal aborts before any live state is mutated.

[OPUS-REVIEWED] 1655efe

Verdict parsed from the review's SHA-scoped output markers for commit 1655efe0870ca1b39de560a44d4406ebcd1ae8a2.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 1655efe0870ca1b39de560a44d4406ebcd1ae8a2: <one-sentence reason>

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 11, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions — GPT 5.6 review of 20e1ceb4a → fixed in 231ffa9ed

All five blocking findings were real. Each fix has a test that fails when the fix is reverted (7/7 mutants caught, including one the first attempt did not catch — see B4 below).

BLOCKING snapshot_remote.py:111 — tag lookup failure destroys existing bucket identity. Fixed. _bucket_tags returned None for any non-zero exit, so a profile merely lacking s3:GetBucketTagging was indistinguishable from an untagged bucket — the deploy guard passed on a bucket it could not read, and put-bucket-tagging then replaced that bucket's kirocrew:managed/kirocrew:site tags. It now treats only NoSuchTagSet as empty and raises DestinationError otherwise, and ensure_bucket merges the backup tag into the existing set rather than replacing it, so an operator's own tags survive a backup run too. Two mutants: fail-open restored, and merge reverted to replace.

BLOCKING snapshot.py:837 — default replace corrupts its rollback copy. Fixed. With both memory and workspace selected, the workspace pass saved the original tree and replaced the live one; the memory pass then re-copied the incoming files over the saved original, destroying the only copy of what was replaced. The memory-tree pass now runs only when workspace is not selected — workspace's own pass already covers those paths. The test restores a bundle over different local content and asserts the pre-restore original is still readable from pre-restore-*/.

BLOCKING snapshot.py:386 — AWS execution errors escape the CLI boundary. Fixed, and I had already hit this myself during the live run without connecting it: engine.run_aws raises SandboxUnavailableError on a host with no sandbox backend, and harden_bucket raises engine.AWSError, neither of which was caught. There is now a single UPLOAD_FAILURES tuple naming every class the AWS path can surface (DestinationError, AWSError, SandboxUnavailableError, OSError, SubprocessError), used by both the upload and the restore-download boundary. Tests assert a controlled message, that the local bundle is still reported as intact, and that no traceback is printed.

BLOCKING snapshot.py:525 — live knowledge database copied without SQLite consistency. Fixed. workspace/knowledge/ holds knowledge.db, whose WAL is routinely megabytes on a real install, and a tree copy reads the database and its sidecars at different instants. Every .db/.sqlite/.sqlite3 under a staged tree is now re-copied through the SQLite backup API in a second pass, and the -wal/-shm/-journal sidecars are excluded from the tree copy.

Worth recording, because my first attempt at this one was wrong: the sidecar-exclusion mutant survived, and investigating showed why — re-opening the staged database makes SQLite discard the copied sidecars as a side effect, so a test using a real database passes with or without the exclusion and proves nothing. The exclusion is not redundant: it covers the case the backup-API pass skips, a file named .db that SQLite cannot open. The test now asserts on exactly that case, and the code comment records the interaction so the next reader does not delete the glob as dead.

BLOCKING snapshot_remote.py:267 — remote restores overwrite unrelated local snapshots. Fixed. Two S3 keys can share a basename, and the download lands in the snapshots dir alongside retained bundles. An existing file is now never overwritten — the next free …​.<n>.tar.gz is used, and 999 collisions is a refusal rather than a silent clobber. The test writes a first bundle, downloads a same-named key, and asserts the first is byte-intact.

FINDING snapshot_remote.py:113 — function-local import json. Fixed: hoisted to module scope. Both occurrences are gone; the rule's exemptions (optional dependency, circular import) did not apply.

FINDING snapshot.py:356 — function-local from kiro_crew.deploy import profiles. Declined, with the cost measured, following the precedent this repo already sets for a lazy import justified by boot-path cost. snapshot.py is imported by cli.py on every kirocrew invocation, while the deploy import is reached only when --to is passed. Measured on this branch: kiro_crew.snapshot alone is 275 modules / 130 ms; adding kiro_crew.deploy.profiles makes it 329 modules / 194 ms — +54 modules and +64 ms on every CLI call for a code path most invocations never take. Same reasoning for the snapshot_remote import inside _upload_bundle. Happy to hoist both if you would rather pay that.

Also in this push

  • Backend Tests shard 3 was red on test_lesson_contradiction.py::test_distinct_words_differing_only_by_sharp_s_are_not_conflated. That file is not in this diff (git diff --name-only touches no lesson or learn module) and the whole file passes locally (54 passed). Watching it on the new SHA rather than claiming it fixed.
  • Gates re-run: 355 tests across the snapshot/destination/portability/beacon/deploy-CSE suites, plus isort, flake8, mypy (878 files), docs-lint and the brand-name gate.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 11, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 11, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions — GPT review of 231ffa9ed → fixed in 181c09d4b

All three blocking findings were real; two I verified by reproducing them first. 3/3 mutants caught.

BLOCKING snapshot.py:542 — file-only snapshots crash when selected files are absent. Fixed, and this was a regression I introduced: the previous code created the staging dirs unconditionally (for d in ("workspace","skills","plan_memory"): mkdir), and driving staging from the registry dropped that. Reproduced before fixing — a fresh home with --components crons raised FileNotFoundError: …/MANIFEST.json. stage.mkdir() now runs before any component. An empty bundle is a valid outcome; a crash is not. Test asserts a bundle is produced with crons.json deliberately removed.

BLOCKING snapshot.py:262 — share snapshots can include cron credentials. Fixed; my declaration was wrong. Confirmed against the code rather than reasoning about it: CronJob.env is a persisted dict[str, str] of per-job environment variables (cron.py:282), so a job passing an API token to a command/script carries that token in crons.json. crons is now UNRESOLVED — unchanged in a backup, refused in a share bundle — with the reason in the spec comment and in the user doc.

BLOCKING snapshot_remote.py:207 — untagged public-origin buckets pass the deploy guard. Fixed, and this is the finding I am most glad of: the tag check only ever caught deploy's own buckets, and Block Public Access does not neutralise a CloudFront origin grant — an OAC statement names the CloudFront service principal with a distribution condition, which is not "public" by S3's definition, so RestrictPublicBuckets leaves it in force. Any pre-existing bucket fronting a distribution would have kept serving an uploaded bundle.

Rather than trying to enumerate every way a bucket can be internet-readable, the invariant is inverted: assert_is_a_backup_bucket accepts a pre-existing bucket only if it already carries kirocrew:backup=true — i.e. we created it, or we have used it before. Everything else is refused, and the message names the one-time put-bucket-tagging command so the refusal is actionable rather than a dead end. A deploy-tagged bucket still gets its specific CloudFront explanation. Documented under "Off-host copies (S3)".

FINDING snapshot.py:320 — function-local from contextlib import closing. Fixed: hoisted to module scope, and the two occurrences this branch added are gone. Stdlib, no cycle, no boot cost — none of the rule's exemptions applied, unlike the deploy.profiles import I declined last round on measured cost.

Notes

  • Backend Tests shard 3, red on the previous SHA with test_lesson_contradiction, is green on this one. It was a flake, as the diff touching no lesson code suggested.
  • Four existing assertions changed as a direct consequence of the two contract changes above (crons policy, and the guard's rename/strengthening from assert_not_deploy_bucket). Each was updated to the new contract, not relaxed: the untagged-bucket case flipped from "allowed" to "refused with an actionable message", and the manifest test now pins crons: unresolved.
  • Gates: 358 tests across the snapshot/destination/portability/beacon/deploy-CSE suites, isort, flake8, mypy (878 files), docs-lint, brand-name gate.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 11, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 11, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions — GPT review of 181c09d4b → fixed in 2665c9752

All four blocking findings were real. 4/4 mutants caught.

BLOCKING snapshot_remote.py:177 — backup tag bypasses deploy-bucket refusal. Fixed. assert_is_a_backup_bucket checked TAG_BACKUP first and returned early, so a bucket carrying both tags was accepted on the backup tag and the bundle would have landed behind CloudFront anyway. The deploy tag is now checked first and is decisive. Two tests: the guard itself, and that ensure_bucket refuses before any object is written.

BLOCKING snapshot.py:575 — component tree roots follow filesystem links. Fixed. _copytree_safe skips links it finds inside a tree, but is_dir() follows the root, so a workspace/memory symlinked at something outside the data home would have had its target copied into the bundle — and then uploaded off-host. Both new tree roots are the plausible case, so the check went in at the staging loop and covers every component's roots. Test symlinks workspace/knowledge at a directory holding a private key and asserts it never appears in the bundle.

BLOCKING snapshot.py:609 — selective bundle manifest ignored during restore. Fixed, and this was the destructive one. With --components unset, _want() answers True for everything, so a memory-only bundle taken through --mode replace would rmtree the live workspace/ and put back only the two memory subtrees the bundle carries — deleting unrelated state the bundle never held. Restore now defaults its component set to what the manifest says actually rode. A pre-v3 bundle has no component map and keeps the all-components behaviour, which is right for it: it did hold everything. Unknown names in a manifest are dropped rather than trusted, so a bundle from a newer build cannot steer this one. Five tests, including that an unrelated workspace/my-notes.md and an unrelated skill both survive, that an explicit --components still wins, and that a full bundle still restores everything.

BLOCKING snapshot.py:904 — replace restore dereferences linked memory roots. Fixed. Same class as above on the destination side, with a sharper failure: is_dir() follows the link so the rollback copy would capture the link's target, and shutil.rmtree refuses a symlink outright — the restore would crash after having already written. A linked destination root is now skipped with a notice. Test points dest/workspace/memory at an outside directory and asserts the restore succeeds, the link survives, and the target's contents are untouched.

Correction — the import finding I declined last round

FINDING snapshot.py:404 — function-local profiles / snapshot_remote imports. Fixed, and my previous rebuttal was wrong.

I declined this on 231ffa9ed with a measured cost: "+54 modules and +64 ms on every CLI call". The premise of that measurement was that snapshot.py sits on every kirocrew invocation's import path. It does not — cli.py imports it lazily inside the subcommand dispatch (from kiro_crew.snapshot import snapshot_main at cli.py:2347, and restore_main at :2353). So the module is only imported when you actually run kirocrew snapshot or kirocrew restore, and in that case the deploy import is paid by the very command that needs it. The cost I cited was real arithmetic attached to a false premise, which made the conclusion wrong.

Both imports are now at module scope. Verified no cycle first: nothing under deploy/ or sandbox.py imports snapshot or portability. Thanks for raising it a second time — that is what prompted me to re-derive the claim instead of restating it.

Gates

368 tests across the manifest/link, review-fixes, destination, purpose, snapshot, portability, beacon and both deploy-CSE suites; isort, flake8, mypy (878 files), docs-lint.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 11, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 11, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions — GPT review of 2665c9752 → fixed in a9305d4f5

All five blocking findings were real. This round I stopped fixing them one at a time: three of the five are the third appearance of an invariant this PR had already patched twice, so they are fixed at the chokepoint with a structural test, not at the reported line. 8/8 mutants caught.

Invariant 1 — component tree roots must never be dereferenced

Found three times in three sites: staging (231ffa9ed), replace (231ffa9ed), and now merge (snapshot.py:977). Each site had been written without the check independently, which is the signal that the check belonged in one place.

safe_tree_root(root, what=...) is now the single chokepoint, and all three sites call it. The harm differs per site — the bundle exports a link's target off-host, the rollback copy captures the target, restored files land outside the data home, shutil.rmtree refuses a symlink and crashes mid-restore — but the cause is one thing.

test_snapshot_invariants.py::TestEveryTreeRootSiteUsesTheChokepoint walks the AST of snapshot.py, finds every for … in ….trees: loop, and fails if any of them does not call safe_tree_root. Mutant V2 deletes the call from the merge site and that test catches it, so a fourth site added later cannot reintroduce the class quietly.

Invariant 2 — a bucket's tag says who intended it; only its policy says who can read it

Reworked three times: tag check added (c2), tag ordering fixed (c3), and now the finding that the tag itself is insufficient — a backup-tagged bucket can still carry a CloudFront OAC grant. That is the same guard being wrong for the third time, so the question it asks has changed.

bucket_policy_grants_foreign_read now reads the bucket policy and refuses any Allow of a read action (s3:Get*, s3:List*, *) to a principal that is not this account — a service principal such as CloudFront, another account, a wildcard, a federated or canonical-user grant. Fails closed: an unreadable or unparseable policy is a refusal, because "we could not tell" and "nobody else can read it" are different answers. NoSuchBucketPolicy is the one non-zero exit that means "no policy", which is the safe case. A Deny statement and an Allow to our own IAM ARNs are both normal and accepted; a write-only grant (e.g. S3 logging) is not treated as read.

Invariant 3 — share-safety is a content question, so stop guessing per component

crons was flipped from guessed-safe to UNRESOLVED in c2; workspace is now flagged for the same reason. Two wrong guesses in two rounds, and the next candidates (skills, notifications, and memory itself — a lesson can hold a pasted token) would have gone the same way.

No component claims share-safe any more. SecretPolicy.NO_REDACTION is renamed SHARE_SAFE and nothing declares it. --purpose share therefore refuses whatever you select, with a message that says why: whether a component is safe to hand to someone is about its content, not its shape, and staging cannot tell. The purpose, the per-component declaration, the manifest record and the refusal are all live and tested, so the first genuinely certified component only has to change its own declaration — and test_no_component_claims_to_be_share_safe fails when one does, forcing that to be a deliberate act with redaction behind it.

This is a narrowing of what M1 claims, not a feature loss: backup — restoring onto a host you control — is what the milestone is for, and it is unaffected.

The two manifest findings

snapshot.py:706 — unknown-only manifests became full restores. Fixed. return known or None collapsed an empty resolved set into None, which falls back to all-components: a bundle from a newer build would have moved the whole current home out and put nothing back. known is now returned even when empty; only a bundle with no component map at all (pre-v3) returns None and keeps the historical behaviour, which is correct for it.

snapshot.py:1130 — explicit restore selections ignored bundle contents. Fixed. --components config against a memory-only bundle would move the live config files to the rollback dir with nothing to put back. An explicit selection absent from a present manifest map is now refused, printing what the bundle actually carries. Test asserts the live config.json is untouched.

Verification

382 tests across the invariants, manifest/link, review-fixes, destination, purpose, snapshot, portability, beacon and both deploy-CSE suites. 8/8 mutants caught, including the AST guard (V2), the fail-closed policy read (V7) and the service-principal case (V8). isort, flake8, mypy (878 files), docs-lint, brand-name gate.

One gap stated plainly: the live end-to-end run against real S3 was done on 20e1ceb4a, before this round changed the bucket guard. The new policy-read path is covered by unit tests against the stubbed run_aws chokepoint but has not been exercised against a real bucket — the sandbox credentials for that account have since expired. The existing test bucket has no bucket policy, so NoSuchBucketPolicy is the path it would take.

Docs updated for both behaviour changes: the purpose section now says share refuses everything and why, and the S3 section documents that marking a bucket is necessary but not sufficient.

Comment thread test/test_snapshot_invariants.py Fixed
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round on 2764a5939: three findings. Two fixed, one rebutted with a measurement.
Plus a CI gate red that was mine. No override posted -- see the last section for why not yet.

Fixed: rollback followed a symlink planted at the destination

_restore_everything_from_rollback handled a saved DIRECTORY with a link check, and a
saved FILE with a bare shutil.copy2. copy2 opens the destination BY NAME and follows a
link, so a link planted at a core file's name between the save and the recovery would send
the restored bytes wherever it pointed. Recovery is the worst possible place for that: it
runs precisely when the operator's state is already half-replaced.

Now routed through copy_file_pinned (O_CREAT|O_EXCL|O_NOFOLLOW), with the existing
destination removed first -- link as a link, file as a file -- because unlike the merge path
this leg is putting back what the restore moved aside, so skipping an occupied name would
leave the failed generation in place.

This is the third instance in this branch of one shape: a guard present in one branch and
absent from its sibling. The first two were the manifest-exemption basename match and the
hardlink skip that a later pass undid.

Fixed: staging directories were not locked down before their children existed

Three TemporaryDirectory staging trees -- the snapshot stage, the restore extract, and the
redaction workdir -- relied on mkdtemp giving 0700. That is a POSIX statement. Windows
derives access from the DACL and ignores the mode, so a permissive parent DACL is inherited
by every child, and on these paths the children are the operator's whole data home in the
clear. All three now call restrict_dir_to_owner BEFORE anything is created inside them.

Worth naming because it also corrects something in this PR's own reasoning: an earlier draft
of the redaction workdir annotation justified a lockdown as unnecessary on the grounds that
mkdtemp is 0700 -- measured true on this host, and irrelevant on Windows. The finding
invalidated the justification, not just the code.

Also fixed: the lockdown-before-publish gate

Backend Lint & Type Check and test_lockdown_before_publish flagged two files locked down
only after their content was written (issue #5307). snapshot_remote.py now writes the host
fingerprint through atomic_write(..., restrict_to_owner=True), so the temp file is
owner-only before the token reaches it. The redaction archive keeps its restrict_to_owner
call with a # lockdown-ok: annotation, because that archive is STREAMED -- routing it
through atomic_write would mean holding a multi-gigabyte bundle in memory to pass as
content -- and with the directory lockdown above it is now genuinely a re-assert rather
than the protection.

Rebutted, with the measurement: refusing on a byte-length change

The finding is the documented offset-corruption gap: with redaction opted in, a
credential-bearing file whose structure depends on byte offsets comes out invalid. Real, and
already stated as a known gap in the description.

The prescribed remedy -- "refuse rewriting when the encoded byte length changes" -- was
measured before answering, because a remedy can be wrong while its finding is right:

case                  in   out  same len?
aws access key        34    36      False
telegram token        56    36      False
slack token           70    36      False
bearer header         71    36      False
length-preserving on 0 of 4 matched cases

A credential and its placeholder differ in length in every case the scanner matches. So the
rule does not narrow the corruption case; it refuses every redaction the pass exists to
perform, turning the feature into an unconditional refusal to upload. That is a different
product than the one under review.

The correct remedy is the one already named as the follow-up: length-PRESERVING substitution.
It is deliberately not in this PR because it changes the output of shared redaction code that
other callers emit -- Slack messages, forge comments, logs -- and widening that is a change
with its own review, not a rider on this one. The gap it leaves is bounded and stated: opt-in
only, off by default, a file that decodes as UTF-8 with no NUL, and the LOCAL archive is never
redacted, so the exposure is a corrupted off-host copy rather than lost data.

No override is posted on this SHA. Two of the three findings are fixed, so the head has
already moved; an override suppresses every finding on the commit it names, and pressing it
while real findings stood would have buried them. If the offset finding is the only blocking
item on the new head, the override belongs there instead.

Tests

794 pass. The one local failure is an AF_UNIX socket path exceeding 108 bytes under this
host's scratch directory and passes under a short TMPDIR.

Two test injections needed re-scoping rather than the product changing: they stub
copy_file_pinned to fail one mutation, and recovery now uses that same primitive, so an
unscoped stub also broke the put-back and the tests would have been asserting that recovery
fails. Scoped to copies out of the bundle; a copy whose source is under pre-restore- is the
recovery leg and passes through. Their comments claimed recovery used shutil.copy2 and was
unaffected -- true when they were written this evening, false as of this change, and corrected
rather than left to mislead.

Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files), docs-lint,
lockdown-before-publish.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

CI round on 698c84b83: two defects, both mine, both found by main's OWN test file for the
primitive this branch re-integrated onto. Fixed on 306bdbf5e.

The unsafe-root refusal was validating against the wrong data home

_refuse_unsafe_destination_roots called safe_tree_root(d, what=...) without passing
home=, so the containment check measured every destination tree against the AMBIENT
_mc_dir() rather than against the mc the restore is actually writing into. For a default
run those are the same directory, which is why it looked correct; for any caller that passes
an explicit destination, every tree resolved "outside the data home" and the restore was
refused unconditionally. The message even named the two different directories side by side:

these destination trees do not resolve inside the data home:
  memory:workspace/memory, memory:workspace/knowledge, skills:skills, ...
Skipping destination root that resolves outside .../i0/1-kirocrew-home:
  .../test_merge_consults_the_gate_b0/home/workspace/memory -> (itself)

A tree resolving to itself and still being rejected is the tell: the base it was compared
against belonged to a different home. Four call sites had the same omission -- in
_trees_absent_from_bundle, _build_snapshot, _refuse_unsafe_destination_roots and
_do_replace -- and all four now pass home=mc.

This one only became reachable this round, because the merge path had previously SKIPPED an
unsafe root instead of refusing; making merge refuse (correctly) is what turned a silent skip
into a hard stop, and the hard stop exposed that the check underneath was measuring the wrong
thing. Worth stating plainly: the earlier silent skip was hiding this.

_build_snapshot made two arguments required and broke existing callers

selected and purpose were introduced as required keyword-only arguments, so callers that
predate the component seam failed with TypeError: missing 2 required keyword-only arguments.
They now default -- purpose=Purpose.BACKUP, and an omitted selected means every component,
which is exactly what snapshot without --components has always produced. Adding a
parameter to a function other code already calls is not the same as adding a flag to a CLI.

How these got past the local gates, and what changed

The local pre-push floor was test_snapshot*.py plus the posture, boot-path, redact-meta,
backup-CLI and lockdown suites. test_pinned_staging.py is main's test file for the
descriptor-pinned primitive, named after ITS feature rather than this one, so a glob keyed on
snapshot never ran it -- and it is precisely the suite that exercises _build_snapshot and
the restore gate with an explicit mc. It is now part of the floor, and the general rule is
recorded: when a change re-integrates onto another module, run that module's own tests, not
just your feature's glob.

847 tests pass on the new head, including main's 53. Also verified, so they are not read as
this branch's: test_design_tweak_backend.py, test_file_sheet.py and
test_host_isolation_floor.py fail on this branch's BASE as well -- 27 failures there in two
of those files alone, against 14 here -- and the diff touches none of them.

Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files), docs-lint,
lockdown-before-publish.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round on 306bdbf5e: two findings. One fixed on 692fc38af; the other is the offset gap,
and its remedy CHANGED between rounds, which is worth putting on the record.

Fixed: a contentless FTS5 index rode the bundle unexamined

FTS5 storage tables (_data, _idx, _docsize) are skipped because they are DERIVED --
cleaning the content table and rebuilding the index is what removes the term. That
justification holds for an ordinary index and for the external-content shape this product
actually uses. It is FALSE for content='': a contentless index has no content table, so its
tokens live only in that storage, nothing scans them, and the rebuild has no source to
regenerate them from.

Measured before writing the guard, and the interesting part is that the first attempt at the
probe said the opposite:

                         scanner_matches  in_storage  after
aws key (uppercase)      True             True        True   <-- LEAK
telegram (has ':')       True             False       False
openai-style sk key      False            True        True
bare 40-char hex         False            True        True

The first probe searched for the credential verbatim and found nothing, which looked like a
clean bill. FTS5's tokenizer lowercases and splits on non-alphanumerics -- so a token-bearing
separator (8412345678:...) is genuinely destroyed as a SHAPE, but an AWS access key is a
separator-free alphanumeric run and is stored as ONE token. Searching case-insensitively is
what turned "nothing survives" into a leak the scanner would have caught in plain text.

The case folding is not a control: an sk-style or hex key is lowercase to begin with, and
those two rows show the bytes surviving intact -- they escape today only because no pattern
matches a bare token, which is a gap in the scanner rather than a protection.

Contentless definitions now REFUSE the upload rather than being scanned. Cleaning FTS5's
private storage in place is not something this pass can do correctly (the format is SQLite's),
and dropping the database would turn a provable-cleanliness problem into data loss -- so it
follows the rule the rest of the module already uses: a payload that cannot be proven clean
refuses the upload and is kept. Mutation-verified: disabling the detection fails the new test.

This is the fifth instance in this branch of one shape -- a rule justified for ONE of a path's
behaviours and then applied as if the path had only that behaviour. The docstring now names
both shapes explicitly so the next reader sees why the skip is conditional.

The offset finding: same finding, third different remedy

Round A: "make substitutions byte-length preserving." Round B: "refuse rewriting when the
encoded byte length changes." Round C, this round: "refuse matched files beginning with
%PDF-."

The finding is real and is documented as a known gap. Each remedy has been answered on its
own merits rather than by pointing at the previous answer:

  • B was MEASURED: 0 of 4 matched credential shapes are length-preserving, so refusing on a
    length change refuses every redaction and replaces the feature with an unconditional refusal
    to upload.
  • C is the per-signature approach declined earlier in this review. %PDF- closes exactly one
    format; ZIP (PK), PNG, ELF and gzip are the same defect wearing different magic bytes, and
    each would arrive as its own round. A guard that has to enumerate formats is not a guard.
  • A remains correct and remains out of scope: length-preserving substitution changes the
    output of SHARED redaction code that other callers emit, which is a change with its own
    review rather than a rider on this one.

The gap is bounded and stated in the description: opt-in only, off by default, a file that
decodes as UTF-8 with no NUL byte, and the LOCAL archive is never redacted -- so the exposure
is a corrupted off-host copy, not lost data.

No override on this SHA: the contentless-FTS fix moved the head, and an override suppresses
every finding on the commit it names. If the offset finding is the only blocking item on the
new head, that is where it belongs.

Tests

848 pass, including main's 53 in test_pinned_staging.py. The one local failure is an
AF_UNIX socket path exceeding 108 bytes under this host's scratch directory and passes under
a short TMPDIR. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint, lockdown-before-publish.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round on 692fc38af: three findings. Two fixed on 007ed5187, the third is the offset gap
whose remedy has now cycled back to a form already answered.

Fixed: a pinned refusal mid-mutation skipped the rollback

_do_replace wrapped its mutation phase in except (OSError, DatabaseCopyFailed), and
PinnedPathRefusal is neither -- it subclasses Exception directly. So the one failure class
phase-two rollback exists for walked straight past it and left live state half replaced.

This exposure was created earlier in this review, not inherited: routing recovery through
copy_file_pinned with a fatal skip reporter, and adding must_create=True to the mutation
phase, are exactly what made refusals reachable there. A fix that introduces a new failure
type has to revisit every handler that was written before that type existed.

Both handlers now name it -- the phase-two wrapper in _do_replace, and the per-target
handler in _restore_everything_from_rollback, where a refusal is recorded per target rather
than aborting the loop and stranding the rest. Phase one is deliberately left outside: a
refusal there happens before any mutation, so there is nothing to roll back and the clean
refusal is the answer.

The regression test was WRONG TWICE before it was right, and both mistakes are worth naming
because each produced a green test that proved nothing:

  • It omitted the fixture that points _mc_dir at an isolated home, so the restore wrote into
    the ambient data home while the assertions inspected an untouched tmp tree. "ORIGINAL
    survived" was true because nothing had run.
  • With that fixed it still never reached the mutation phase: the payload used a byte-string
    stand-in for memory.db, which the source-integrity pre-flight correctly refuses, and it
    omitted workspace/knowledge, which the absent-tree pre-flight correctly refuses.

Both were caught by mutation-testing the handler and seeing GREEN. The test now asserts
"Replace mode" in out before anything else, so a future setup error fails loudly instead of
passing vacuously. Mutation-verified in its final form: removing PinnedPathRefusal from the
handler fails it with no such table: t -- the live database not put back.

Fixed: rollback-name exhaustion escaped the CLI boundary

_allocate_rollback_dir raises SourceComponentUnsound when every candidate name for the
current timestamp is taken. restore_main caught that type around the pre-flight validator
but not around the execution boundary, so it surfaced as a traceback. It happens before any
mutation, so it is a refusal like the others: audited, reported in one sentence, rc=1.

One of main's tests needed adjusting, and why that is not a weakening

test_the_replace_path_refuses_a_root_recreated_after_its_own_rmtree asserted the exception
TYPE of a refusal escaping _do_replace. It now arrives wrapped, because that test patches
shutil.rmtree to recreate the root it removes -- which also sabotages the recovery leg, so
recovery legitimately cannot complete and the refusal is re-raised as the cause of a
RollbackIncomplete. The message is byte-identical.

The test's stated point is the WIRING: proving must_create=True reached the call site, which
the message is what establishes. It now accepts either form and still requires that message
and still requires the refusal to be a PinnedPathRefusal. Asserting the raw type would now
be asserting that a mid-mutation refusal must SKIP the rollback, which is the opposite of
correct.

The offset finding: remedy has cycled

Four remedies across four rounds -- length-preserving, refuse-on-length-change, refuse-%PDF-,
and now "preserve byte length, or refuse any rewrite whose encoded length changes", which is
the first two restated together. Both halves are already answered on their merits: the
preserving form is correct and reaches SHARED redaction code other callers emit, so it belongs
in its own change; the refusing form was MEASURED to refuse every redaction (0 of 4 matched
credential shapes are length-preserving), which removes the feature rather than narrowing the
bug. The gap stays as documented: opt-in only, off by default, UTF-8-decodable with no NUL,
and the local archive is never redacted.

No override on this SHA -- two of three findings are fixed, so the head has moved, and an
override suppresses every finding on the commit it names.

849 tests pass, including main's 53. Gates clean: subprocess-encoding, black, isort, flake8,
mypy (1105 files), docs-lint, lockdown-before-publish.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fixed on 3a9e977b3. GPT called this one correctly, and reproducing it showed it is worse
than the report: FOUR files, not one, and the rollback reports SUCCESS while doing it.

What actually happens

_do_replace_mutations populated the rollback ledger with COMPONENTS[comp].files -- every
file the component DECLARES -- before the copy loop ran. The copy loop skips any file the
bundle does not carry, so those files are never backed up and never written. Recovery's third
case then reads "in installed, no saved copy, target exists", concludes the restore created
the file, and deletes it.

Reproduced on config, whose five core files are all declared. A bundle carrying only
config.json leaves session_map.json, hooks.json, project_dir and workspace_dir
untouched and unsaved:

installed after the copy loop = ['config.json', 'hooks.json', 'project_dir',
                                 'session_map.json', 'workspace_dir']
  'session_map.json': in installed=True  saved in rollback=False
  ...
recovery reported failures: []
"Previous state restored."

DELETED: 'session_map.json' -- untouched by this run, no saved copy, removed by rollback
DELETED: 'hooks.json'       -- same
DELETED: 'project_dir'      -- same
DELETED: 'workspace_dir'    -- same

Four files of the operator's own data gone, and the rollback printing "Previous state
restored." with an empty failure list. Silent loss reported as a successful recovery is the
worst outcome this code has, so the report understated it rather than overstating it.

The recovery function's own docstring already specified the correct behaviour -- "Not saved,
and this run never reached it -- LEFT ALONE ... Removing those deletes the operator's own data
that this restore never so much as opened" -- and so did the ledger's: "every declared path
this run BEGINS WRITING, recorded BEFORE the write". The code recorded every path the
component DECLARES. The contract was right and the population was wrong.

Seventh instance of the pattern this branch keeps producing

A rule justified for ONE of a path's behaviours, then applied as if the path had only that
behaviour. Here installed is read as "was reached" and was written as "was declared". The
previous six: manifest basename vs rel; the hardlink skip undone by the restage; the rollback
file-branch missing the dir-branch's link check; safe_tree_root measured against the ambient
home; contentless FTS; PinnedPathRefusal outside the rollback handlers.

Fix

_backup_and_copy now owns the ledger and is the only writer to it: a name is added
immediately before that file's own first mutation, in both the pinned and fallback branches,
directly after the skip check. A file the bundle omits is never recorded, so recovery reads it
as never touched. installed is an OPTIONAL keyword, so main's existing callers
(_backup_and_copy(mc, backup, snap, "crons")) keep working -- the same mistake as making
_build_snapshot's arguments required, avoided this time by checking callers first.

Two regression tests, because a fix that merely stops the deletion could do it by breaking the
legitimate undo:

  • a file the bundle omits survives the rollback with its bytes intact;
  • a file the restore really CREATED is still removed by the rollback.

Both mutation-verified, each killed by a different mutant. Restoring the declared-set
population fails the first ("the ledger recorded files the copy loop skipped: ['hooks.json',
'project_dir', 'session_map.json', 'workspace_dir']"). Never recording at all -- the naive
"just stop deleting" fix -- fails the second.

One ordering invariant relaxed, and why it is not weaker

test_snapshot_cycle8_fixes.py located the database swap by the exact text
_backup_and_copy(mc, backup, snap, comp, which no longer exists now that the call takes
another argument. The locator is now the function name; the swap_at < replace_at assertion
is untouched. Verified the locator is still unambiguous -- it occurs exactly ONCE inside
_do_replace_mutations, so it cannot match a docstring mention and leave the assertion
vacuously true, which is the way this kind of relaxation usually goes wrong.

851 tests pass. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fixed on 17aa3f6a2 rather than overridden, and this round reverses a judgement I made
earlier in this review.

The override precondition was satisfied for the first time -- this was the SOLE blocking item
on the head. I did not use it, because measuring the claim showed part of it was a defect I
had never answered, and a real reachable defect gets fixed.

The remedy was new, so it got a new answer

Four remedies for this finding have been answered here already: length-preserving replacement,
refuse-on-length-change, refuse %PDF-, and the first two restated together. This round's is
different in kind -- refuse credential-bearing STRUCTURED FILES and BYTE-VALUED DATABASE
FIELDS. It keys off the nature of the payload rather than off a length delta or one magic
number, and the database half had never been raised at all.

The database half was real and unanswered

The file path refuses a NUL-bearing file precisely because a variable-length edit shifts every
following byte. A byte-valued column had no equivalent: it decoded latin-1, scrubbed,
re-encoded, and wrote the result back on any hit. Reproduced on a length-prefixed record --
the shape of every serialised blob:

before: blob len=54 declared_payload_len=46
redaction returned without refusing
after:  blob len=56 declared_payload_len=46 actual_payload_len=48
  the trailing field now reads b'de\xdd\xcc', not b'\xdd\xcc\xbb\xaa'

The prefix still describes 46 bytes, the payload is 48, the trailing field is shifted -- and
the pass returned normally, so that database went off-host reported as clean. Silent
corruption reported as success is the worst outcome this code has.

The existing comment on that branch is the giveaway, and it is the same pattern this branch
keeps producing -- EIGHTH instance: "a value with no hit is never rewritten, which is what
keeps embeddings and other real blobs intact" is a defence of the MISS, written on a branch
whose HIT case is the hazard.

The fix is not the prescribed one, and the difference matters

The prescription was to refuse byte-valued fields. That over-refuses: sqlite stores plain
UTF-8 text as a BLOB routinely, that case has no offsets to invalidate, and refusing it trades
a credential this pass can safely remove for a refused upload -- the same failure mode measured
for the refuse-on-length-change remedy, which refused every redaction.

So the test is the FILE path's own test, applied per value: text-shaped bytes (decodable UTF-8,
no NUL) are rewritten, structurally binary bytes are refused. Symmetric, and it refuses exactly
the shape where corruption was measured.

A prior judgement of mine, reversed by measurement

I dismissed refuse-%PDF- on the grounds that it fixes one shape and leaves ZIP, PNG and the
rest. That reasoning was wrong, and the PR body carried it as a stated reason. Measured:

uncompressed ASCII PDF     utf8=True  nul=False -> REACHES THE REWRITE
PDF with a Flate stream    utf8=False nul=True  -> already refused
ZIP                        utf8=False nul=True  -> already refused
gzip                       utf8=False nul=True  -> already refused
PNG                        utf8=False nul=True  -> already refused
tar                        utf8=True  nul=True  -> already refused
plain .md / .json / .log   utf8=True  nul=False -> REACHES THE REWRITE (no offsets, harmless)

Every other offset-dependent container is ALREADY refused by the NUL guard. The uncompressed
ASCII PDF is the only text-shaped one that reaches the rewrite, so refusing it is not an
arbitrary special case -- it is the residual. Added, and the PR body's stale "known gap"
paragraph is rewritten to say what is now true, including that the earlier dismissal was wrong.

Narrowed, not closed, and the body says so: a text-shaped format with internal offsets that is
not PDF would still be rewritten. Length-preserving replacement in the shared egress code
remains the general answer and still belongs in its own change.

Tests

Four new, each mutation-verified -- the blob refusal and the PDF refusal are killed by their
own test, and two "must still work" tests (a hit-free blob rides byte-for-byte; an ordinary
text file is still redacted) stay green under both mutants, which is what stops the fix from
being an unconditional refusal.

Two existing tests changed, and one of them had encoded the defect:
test_binary_around_a_credential_is_preserved_exactly asserted the blob WAS rewritten with its
surrounding bytes intact. Both its assertions were true and neither could see the bug -- head
and tail were still present and still at the two ends, while everything between them had moved.
It now asserts the refusal, and its codec claim (latin-1 is byte-preserving, which a UTF-8
round-trip is not) moved to a non-ASCII text-shaped value, the branch that still rewrites. The
branch-count invariant that enumerates "every state this pass cannot prove clean must refuse"
went from three states to five, with the two new ones named.

856 tests pass. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint.

Overrides used in this review: still zero.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fixed on f5b3f5966. Opus 4.8, First Principles, Design Review and UX Review all pass on
the previous head; this was GPT's one remaining blocking item.

A saved core-file symlink was skipped by the rollback

Recovery tested saved.is_dir() then saved.is_file(). Both DEREFERENCE, and a core file
that was a relative symlink stops resolving the moment it is moved into the rollback
directory -- so it matched neither branch. Reproduced:

live crons.json is a link -> real-crons.json  (resolves: True)
saved in rollback: is_symlink=True is_file=False is_dir=False   <-- both branch tests
"Previous state restored."   recovery reported failures: []

LOST: the live name does not exist at all.
  the operator's symlink is still sitting in the rollback directory
  the replacement the restore wrote was DELETED as an undone creation

Same signature as the ledger finding two rounds ago: silent loss of the operator's own data,
reported as a successful recovery.

A link in the rollback directory is not an exotic state -- _backup_and_copy deliberately
MOVES a symlinked core file aside and prints that it did. The producer creates a state the
consumer could not handle.

Ninth instance, and the code said so out loud

The directory branch carried this: "_backup_tree_or_refuse reports a skipped entry as fatal,
so a tree containing a link never reaches the rollback directory at all -- whatever is in
backup is links-free by construction."

That is TRUE of trees and FALSE of core files, which a different function saves with the
opposite behaviour. A rule justified for ONE of a path's inputs, then read as if the path had
only that input -- the ninth instance of the shape this branch keeps producing, and the second
time the rollback's link handling has been wrong (the first was the saved-FILE branch using
bare copy2 where the saved-DIRECTORY branch had a link check). The comment is now scoped to
trees and says why the broader reading was wrong.

Fix

A saved link or junction is detected FIRST, ahead of both dereferencing tests, and the link
itself is put back. MOVED rather than copied, unlike the two branches below it, for two
reasons: the save moved the link, so the rollback holds the only copy; and a move is the one
operation that also reinstates a Windows junction, whose target cannot be read portably.
Mutation-verified, and paired with a test that a saved REGULAR file still comes back, so the
new branch cannot swallow the ordinary case.

An existing structural invariant needed rewriting, and the first two attempts were wrong

test_recovery_clears_a_linked_root_as_a_link_before_rmtree asserted a first-occurrence text
ORDER over the whole function, and said so in its own docstring: "the ONE surviving
clear-a-possibly-linked-root site is recovery". There are three now, so that form broke.

Attempt one pinned the gate to the exact string elif target.is_dir():. It failed on a site
that was correctly guarded but spelled differently
(if target.is_dir() and not is_link_or_junction(target):) -- pinning a spelling instead of
the property.

Attempt two accepted either spelling by looking for a link check within eight lines above. It
passed against a deliberately unguarded rmtree: a nearby link check belonging to a DIFFERENT
branch vouched for it. A relaxation that admits a mutant is a vacuous invariant, and only
mutation-testing showed it.

The form that holds: for every shutil.rmtree(str(target)), the gate must test
target.is_dir(), and a link must be excluded either in that gate or by the CHAIN HEAD found
by INDENTATION -- the nearest preceding line at the same indent opening with if. Not a line
window, because that is what let an unrelated arm vouch. This is strictly stronger than what
it replaced: the original only ever examined the first occurrence of each string, so a second
unguarded rmtree added later satisfied it. Both mutants (an ungated rmtree, and an
is_dir()-gated one reachable for a link) are killed.

Screenshot Evidence is not a failure

gh pr checks showed it red; the check-run conclusion is cancelled, which is the
double-dispatch noise this repo produces when a run is superseded. No action.

858 tests pass. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint. Overrides used in this review: still zero.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Fixed on 722d8207a. Both hunks addressed, and the answers are different because the two
hunks were not the same problem.

The mkdir hunk was pure redundancy -- deleted

dd.parent.mkdir(parents=True, exist_ok=True) sat directly above
dd.mkdir(parents=True, exist_ok=True), which already creates every parent. It was a second
by-name mutation of a chain the next line covers. Flagged as by-name path reuse, and correctly:
the right answer was to remove it, not to guard it. Deleted; the full suite is unchanged at 860
passing, which is what establishes it was redundant rather than load-bearing.

The sqlite hunk: the finding is right, the prescription is not achievable, and my first fix

for it was wrong

The prescription was "revert both by-name hunks until validation and I/O use the same pinned
descriptors". Reverting this one does not achieve that, and measurement is what shows it:

  • sqlite3.connect takes a PATH, never a descriptor, so no revert makes a SQLite open
    descriptor-pinned.
  • The pre-hunk code was ALSO a by-name URI open. This hunk changed the ESCAPING, not the
    by-name-ness: interpolating the path raw let a POSIX filename containing ? or # truncate
    the URI and open a different database. Reverting reinstates that bug and removes nothing.
  • Main's own snapshot.py opens SQLite by name in SIX places, including
    sqlite3.connect(str(src)) in the structurally identical creation-side database copy.

What IS real in the finding, and what I fixed: src.resolve() re-walked every ancestor BY NAME
after the loop had screened the file, so a swapped ancestor redirects the open at a database
this pass never inspected -- someone else's, whose rows then ride in the bundle under an
innocuous name.

The chain from the source root down to each file is now verified COMPONENT BY COMPONENT through
descriptors, each opened O_NOFOLLOW (_chain_is_link_free), so a component that is a link, or
one swapped for a link mid-pass, fails its own open instead of redirecting the walk. Once no
component is a link the path AS GIVEN names the verified file, so the URI is built from it
without resolving anything -- keeping the escaping fix.

Stated in the code rather than implied: this does NOT close a swap of the FINAL name between the
check and SQLite's own open. A post-hoc identity re-check does not help, because swapping back
defeats it. Closing that needs a descriptor-taking VFS, which is a change to how this module
opens every database, not to this line.

My first attempt at that fix was wrong, and my own comment was the false claim

I first pinned the parent via pin_parent(os.path.realpath(src.parent)) and checked the final
name through it. That resolves the parent at the SAME late moment src.resolve() did, so it
followed a swapped ancestor exactly as before -- while the comment I had written claimed
ancestor redirection was eliminated. Only mutation-testing exposed it. A comment asserting a
property the code does not have is the same defect class this review has already removed from
this PR twice.

The test was vacuous first, for a reason worth recording

The obvious test plants the symlink before the pass runs. It proves nothing: rglob does not
descend a symlinked directory, so the file is never enumerated and the test passes whatever the
code does. A mutant restoring src.resolve() sailed through it.

The claim is about a swap AFTER enumeration and screening, so the swap is now INJECTED from
inside the chain check on its first call, and the test asserts the swap actually fired before
asserting anything about the outcome. Mutation-verified in that form: with src.resolve()
restored, the staged database comes back holding ['EXTERNAL-SECRET']. Paired with a test that
an ordinary database is still restaged -- including a row written after the filesystem copy, so
it proves the consistent snapshot still happens rather than just that nothing was refused.

860 tests pass. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint. Overrides used in this review: still zero.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

On 8800468aa. Three findings, three different answers: one fixed as prescribed, one fixed
NARROWER than prescribed, one declined with reasons.

Fixed as prescribed: duplicate component names crashed creation

Reproduced: --components config,config reaches the staging pass twice for one component and
the second pass hits the exclusive create the pinned primitives make.

snapshot_main --components config,config
  -> FileExistsError: [Errno 17] File exists: .../kirocrew-partial-.../config.json

An uncaught traceback rather than a snapshot. resolve_components now collapses duplicates
with order preserved. A repeated name is a typo, not a request to stage anything twice, so
collapsing is the honest reading rather than refusing the run. Now returns ['config'] and the
snapshot completes.

Fixed, but NARROWER than prescribed: a trigger that DELETES rows

Reproduced, and it is the worst shape again -- permanent loss reported as success:

audit rows before: 2
redaction returned WITHOUT refusing
audit rows after: 0

An AFTER UPDATE trigger removed both rows of an unrelated table. The uploaded copy
permanently lacks them; the local archive is unaffected.

This is the TENTH instance of the recurring shape. The fixpoint scan here is justified in its
own comment for ONE trigger behaviour -- copying a pre-update value somewhere the pass has
already been, which the next pass cleans -- and was standing in for all of them. A DELETE runs
once and settles immediately, so the fixpoint sees a quiet database.

The prescription was to refuse every non-FTS UPDATE trigger. I did not do that, because it
contradicts two things that are already true here:

  • the fixpoint is a deliberate, TESTED capability for the copy-the-value case
    (test_a_trigger_copying_the_old_value_is_still_cleaned), and a blanket refusal discards it;
  • an external-content full-text index is MAINTAINED by update triggers, which the existing
    comment already records as the reason blanket refusal was rejected.

I implemented the blanket form first and the suite said so immediately: it failed both existing
trigger tests. That is the suite doing its job -- the broad fix was a regression wearing a
security fix's clothes.

So the refusal is scoped to UPDATE triggers whose body DELETEs, with FTS-maintenance triggers
exempt even when they delete (removing the stale index row is how they keep the index in step,
and the index is rebuilt after this pass regardless). Mutation-verified, and paired with the
must-still-work half: a trigger that only writes values is still handled by the fixpoint, with
no credential left behind.

Residual, stated in the code rather than implied: this reads the trigger BODY for a DELETE, so
a trigger that destroys a row by other means -- INSERT OR REPLACE over an existing key -- is
not caught. Bounding that needs the effects rather than the text, which is separate work.

Declined: a present redaction.json without the key

Graded FINDING rather than BLOCKING, and it is a judgment call, so here is the reasoning
rather than a flat no. I implemented the raise first, and an existing test with its own stated
rationale failed: test_a_key_the_operator_did_not_set_is_off_not_an_error -- "an unrelated
key is a file that simply does not opt in".

The distinction that decides it is between a value that is SET and cannot be interpreted, and a
key that was never set at all:

  • {"redact_uploads": "true"} or 1 -- the operator plainly tried to set the switch, and
    resolving it either way would override their intent. That RAISES, and still does.
  • {} or {"other": 1} -- nothing was attempted, so the default applies.

The default is off because redaction is opt-IN, and off is the fail-safe direction for this
particular switch: no rewriting means no chance of corrupting the off-host copy, and the
destination is owner-only either way. The claim that this "silently selects an unredacted
upload despite the documented refusal contract" reads the contract as covering absence; the
contract covers an uninterpretable VALUE. Raising on absence would also let a stray file block
every upload.

862 tests pass. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105 files),
docs-lint. Overrides used in this review: still zero.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

On 0b1e4f667. GPT 5.6 came back CLEAN on the previous head -- "no blocking issues, no
findings" -- alongside Opus 4.8, First Principles, Design Review and UX Review. That left one
real red, and it was MINE, introduced by the fix two rounds ago.

Windows: the pinned chain check crashed the whole database pass

_chain_is_link_free walks the source chain with os.open(part, ..., dir_fd=fd). On Windows
os.supports_dir_fd is EMPTY -- this repo's own test says so in as many words -- so that call
raises NotImplementedError. Not "weaker on Windows": the database restaging pass went down
entirely. Backend Tests (Windows) (4) red, the other three shards green, which is the shape
of one code path rather than an infra flake (conclusion=failure, not the cancelled
double-dispatch noise).

pinned_fs.supports_pinned_walk() exists for exactly this, and its own docstring says it was
written after the same mistake: "Found by the Windows-simulation tests, which delete
os.O_NOFOLLOW and would otherwise have taken this path and crashed." I did not consult it.

The gate is now in, and the degradation is stated rather than implied: where the platform
cannot open relative to a directory descriptor, the check returns True and the pass proceeds on
the by-name screening the loop already did -- regular file, not a link, not a reparse point.
That is weaker, and it is the same degradation the rest of this module applies through
_staging_is_pinned. Returning False instead would refuse every database on Windows, turning a
hardening into an outage.

The test simulates Windows, and had to simulate BOTH halves

Patching the capability report alone is not enough: on Linux os.open still accepts dir_fd
whatever os.supports_dir_fd claims, so a missing gate would sail through. The test empties
the capability set AND makes os.open refuse a dir_fd, which is what Windows actually does.
Mutation-verified: with the gate removed it fails with NotImplementedError, the real symptom.

One detail worth recording because it made the test error rather than fail: pytest's own
tmp_path teardown uses os.open(..., dir_fd=) on Linux, so an unconditional stub blows up in
teardown and the simulation ends up testing the harness. The stub is scoped to the pass under
test.

The remaining red is not this diff

Frontend Coverage Merge -- the known formal-honorific ratchet on src/i18n/style/hiStyle.test.ts
(#5843), red on this branch's base. The
checkable fact: of the 62 files in this diff, ZERO are frontend files. The Frontend Tests shards
themselves passed this round.

863 tests pass locally. Gates clean: subprocess-encoding, black, isort, flake8, mypy (1105
files), docs-lint. Overrides used in this review: still zero.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

First Principles round 1 on 1e05bed99 -- BLOCK dispositioned, plus a correction this review made to my own PR description. New head 00e63ef8d.

Blocker: dead --aws-profile -- FIXED

Reproduced exactly as described. --aws-profile was defined on the snapshot parser and read by nobody: after the shrink, aws_profile had zero occurrences on the snapshot path (the remaining hits in src/ are the voice/narration subsystems, unrelated). Its help text advertised --to-s3, a flag this PR deletes. It was a fossil the shrink missed, and the description's claim that the transport was removed was contradicted by it.

Took the subtraction: the argument is deleted. Verified aws_profile now has zero references across snapshot.py, cli.py and cli_help.py, and 1018 CLI tests pass with the flag gone.

The same fossil in the neighbouring comment -- FIXED

Not in the review, but it is the same defect one line up, so it goes with it. --to is retained deliberately, and its comment justified that by saying argparse would otherwise accept --to as an unambiguous abbreviation of --to-s3. That hazard died with --to-s3: dropping --to now would simply produce "unrecognized arguments". The retention is still worth it -- an operator whose old cron line says --to s3://bucket/prefix is better served by a pointer to where the capability went than by exit 2 -- so the flag stays and the comment now states that reason instead of the retired one. A comment asserting a property the code no longer has is the same defect class as the code being wrong.

Correction to my own description -- WITHDRAWN

The review is right and I was wrong. My description offered to split security.py out as a scope question, describing it as "+88/-68 adding _PARAM_TRANSFORM_RE, _SCRIPT_EXECUTES_RE and _BRACE_WITH_OPERAND_RE". Those three regexes exist on the merge base with identical occurrence counts -- the diff only reformatted them. I read a black-reformat diff as new scanner logic and escalated a scope decision that did not exist.

Acted on it rather than only correcting the prose: security.py is reverted to the base and the one substantive change re-applied on top, so the file is now +15/-0 -- the "backup" sensitive-path entry and nothing else. That entry belongs here exactly as the review says, since it is what fences backup/redaction.json. Because the file returns to its base formatting it is baseline-covered again, so the single .github/black-baseline.txt line this PR touched is reverted too and that file now drops out of the diff entirely.

The description has been corrected; the offered split is withdrawn as moot.

Watch: --purpose share and SecretPolicy -- ACCEPTED AND DEFERRED

Confirmed: Purpose.SHARE is only ever consumed by the branch that refuses it, and SHARE_SAFE is constructed nowhere, so --purpose share is a CLI choice with no reachable success path today. The reasoning is sound and I am not disputing it.

Declining to remove it in this PR, for a reason narrower than "so we can later": the manifest purpose/policy field is what the redaction half's documented contract refers to when it explains which components could ever be certified safe to hand to someone else, so removing the enum now means editing the schema and that documentation mid-review, on a change that is already being reshaped by a shrink. That is a larger and riskier edit than the blocker it sits next to. Recorded for a follow-up that removes Purpose, SecretPolicy and --purpose together while keeping the manifest components list, whose consumer is real.

Watch: drop prepare_redacted_copy's selected parameter -- DECLINED, with the reason

Measured before answering. Production has one caller and it always passes the full component set, so the review's count is right. The parameter is not unused variability, though: it is what _report_unresolved_payload discloses over, and that disclosure is the notice telling an operator which components in THIS bundle carry payload that redaction could not resolve. Hardcoding the full set inside the function would make that notice a claim about the whole store rather than about the bundle actually being sent, which is wrong for any partial caller and cannot be detected from the outside. The redaction tests exercise the parameter with a narrower set for that reason.

Happy to be overruled if the preference is to hardcode now and reintroduce the parameter when a partial sender exists.

Not from this PR

Frontend Lint & Type Check is red on a jscpd duplicate in website/scripts/capture-chatpane-upload-error.mjs. This diff contains zero frontend files, and that file does not exist in this branch at all -- it landed on the base after this branch's merge base. Base-owned drift, cleared by a rebase rather than by anything here.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT 5.6 round 1 on 00e63ef8d. Both findings reproduced before answering; one is rebutted with a measurement, the other is rebutted as stated but led to a real defect that is now fixed. New head 78abcc71c.

Finding 1 -- forced restore writing outside the data home: NOT REPRODUCIBLE as stated, but it found a real defect next to it

Built a probe that does exactly the described chain. Getting it honest took three attempts, and the first two are worth naming because each was a way of proving nothing:

  1. A hand-rolled bundle was rejected as "Invalid snapshot format" and never reached the mutation phase. The bundle is now produced by the product's own snapshot path.
  2. Planting the symlink before calling restore only proves the pre-flight works -- _refuse_unsafe_destination_roots catches it and prints "Nothing has been changed." That is not the claim: the claim needs the swap to land AFTER the pre-flight. It is now injected from inside a wrapper around the pre-flight, so the real check runs and passes on an honest home and only then does workspace become an external link. The wrapper's fire count is asserted, and so is the presence of "Replace mode" in the output, or the probe would be green for the wrong reason.

With the swap landing in the actual window, nothing was written outside the data home and both planted external files survived. The write is refused at use time, not merely at pre-flight: stage_tree_pinned pins the DESTINATION chain as well as the source, and open_dir_pinned pins the chain above each root, so the swapped ancestor fails O_NOFOLLOW. The prescribed subtraction -- reverting the memory-tree mutations -- would remove the parent creation that makes --components memory restore onto a fresh machine work at all, and would not close a hole that is already closed.

But the probe surfaced something worse in the same loop, and it is mine. The run reported rc=0, printed "memory" as done and "Replace complete." while BOTH memory trees had been silently dropped. Cause: the mem_roots build did

if safe_tree_root(d, what="destination root", home=mc) is None:
    continue

so a root that failed the check was removed from the set entirely -- neither saved into the rollback copy nor restored -- and the run continued to success. An operator restoring after losing a machine would have believed memory came back when only the databases had.

_refuse_unsafe_destination_roots exists precisely to end that, and says so in its own docstring: "Checking inside the per-tree loops was too late in the worst way ... skipping an unsafe markdown tree left memory split between two versions -- and the command still reported success." The pre-flight was hoisted; this one site kept the old skip. A guard added in one place and not carried to the other, which is the same shape as the must_create gap _copytree_safe already documents.

Fixed: that site now refuses with a message saying the root stopped resolving inside the home AFTER the pre-flight passed, i.e. something moved mid-run. Safe to raise there because it runs before phase one, so no live state has been mutated. Pinned by a regression test that injects the swap mid-run and asserts its own injection fired; mutation-verified by restoring the continue, which turns the test red on assert 0 != 0 -- the old behaviour's reported success.

So: the stated escape is rebutted, and the finding is credited with the defect it led to.

Finding 2 -- refuse non-FTS UPDATE triggers: REBUTTED, measured

Applied the prescription to the current tree and ran the suite. It breaks three existing tests, and they are the ones that define what this branch does about value-writing triggers:

  • TestATriggerThatDeletesRowsRefusesTheUpload::test_a_trigger_that_only_writes_values_is_still_handled_by_the_fixpoint
  • TestATriggerCannotPutBackWhatWasRemoved::test_a_trigger_copying_the_old_value_is_still_cleaned
  • TestATriggerCannotPutBackWhatWasRemoved::test_a_database_that_never_settles_is_refused

The prescription discards the fixpoint. A trigger that copies a pre-update value somewhere the scan has already been is CLEANED by the next pass today; refusing instead means the operator gets no backup at all where they currently get a correctly-scrubbed one. It also refuses the product's own external-content full-text index, whose maintenance is exactly such a trigger -- so the redaction option would refuse the ordinary case.

The underlying concern is real and narrower than the prescription: a write-only trigger can touch a row unrelated to the one being redacted, so the outbound copy can differ from the original by more than the intended substitutions. Telling an unrelated write apart from a derived one needs the trigger's EFFECTS, not its text -- the same separate work this refusal already names as its residual for INSERT OR REPLACE. That residual is now stated explicitly for the write-only case too, including why the wider refusal was rejected and what it cost when measured, so the next reader does not have to re-derive it.

Unrelated to this diff

Frontend Lint & Type Check is red on two jscpd clone pairs among website/scripts/capture-*.mjs. This diff has zero frontend files and two of those four files do not exist in this branch. The base's last green run predates one of them landing, and the base tip carries both sides of that pair, so a rebase inherits the same red -- it clears from the base side.

Backend Tests (Windows) (4) is red and is NOT from this round: it was already failing on the pre-shrink head. It is this branch's own -- a family of snapshot and restore tests that hit stage_tree_pinned's refusal on a platform with no directory descriptors. Being surfaced separately rather than patched inside a review round, because what Windows users should get from the pinned-staging design is a decision, not a test edit.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round 3 on b0516a991 -- both findings reproduced and FIXED. New head e6af5cc9c. The value-writing-trigger finding from round 2 is gone from this verdict; the residual it pointed at is now stated in the refusal's own docstring, which appears to have been what was missing.

Merge installs malformed cron JSON -- FIXED

Real, and the reachable case is the one that matters most. _refuse_unless_json_object was called with installed=mc_for_merge is None, and its docstring justified skipping the refusal on merge by asserting "Merge never installs it: the per-component merger reads the bundle's copy, reports a file it cannot parse, and returns without writing live state."

That is true of the branch that MERGES and false of its sibling:

if dst.is_file():
    _merge_crons(src, dst)
else:
    shutil.copy2(str(src), str(dst))   # no validation anywhere downstream

An absent destination is the fresh-machine case -- the scenario an off-host backup exists for -- so the one path that skipped this check was the likeliest one to need it.

Reproduced: a well-formed JSON ARRAY as crons.json in the bundle (the cron loader's isinstance(data, dict) else [] branch reads that as zero jobs), no live crons.json, merge. Result before the fix: copied verbatim, rc=0, "crons" reported done. Every scheduled job silently discarded with nothing raised and nothing to retry. After: rc=1, nothing installed, no false success.

The gate now asks "will this reach a consumer" rather than "is this a replace": mc_for_merge is None or not (mc_for_merge / name).is_file(). The docstring's "never" is corrected to "usually does not", with the copy branch named, because leaving a premise the reproduction falsified next to the fix is the same defect class as the bug.

One existing test had to change and it is stronger for it. test_merge_skips_a_crons_file_of_the_wrong_shape_rather_than_installing_it asserted the pre-flight standing aside on the grounds that _usable_cron_shape covers the merge path -- while passing a home with NO crons.json, i.e. exercising the branch that guard never runs on. It now asserts both directions: stands aside when the destination exists (the merger's guard is real there), refuses when it is absent (nothing downstream checks). Mutation-verified: restoring the old gate makes the new direction fail with DID NOT RAISE.

Windows junction removal breaks rollback -- FIXED

Real, and the codebase already had the answer. platform_compat.unlink_link_or_junction exists, and is_link_or_junction's own docstring says "Pair with unlink_link_or_junction to remove one safely" -- a junction is a directory reparse point that needs rmdir, and Path.unlink raises on it.

Three sites detected with is_link_or_junction and then removed with plain Path.unlink: the replace path clearing a linked tree, and two recovery branches. A fourth site -- the else of the directory check in the undo-the-creation branch -- covers a link or junction as well as a plain file and had the same gap. All four now go through the helper, which falls through to unlink for an ordinary file so the file case is unchanged.

Worth noting for the record: a fifth site in the same function was ALREADY using the helper correctly. The correct form was known and had not been carried to its siblings, which is the same shape as two other findings on this PR (a guard added to the pinned path and not the by-name one; a pre-flight hoisted ahead of the mutation while one site kept the old skip). I have stopped treating that as a coincidence.

Verification

Linux: 678 passed, 0 failed across the snapshot, redaction, staging, posture and lockdown suites. subprocess-encoding, black, isort, flake8, mypy (1127 files) and docs-lint all clean.

Still open, and not from this round

Backend Tests (Windows) remains red on a large family of this branch's own tests that drive snapshot or restore without --allow-unpinned-staging, which the pre-existing refuse-rather-than-fall-back design requires where there are no directory descriptors. Being fixed separately: the product behaviour is correct and it is the tests that have to say what an operator on that platform has to say. Partially landed already; the remainder is mechanical and shares that single cause.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT rounds 4 and 5. Round 4's merge finding was REAL and is fixed. Round 5 asks for two things I am not doing inside this PR, and I would rather say why in writing than patch a fourth round in the same span. Head c93a80a9b.

Round 4 -- merge escaping through a destination root swapped after the pre-flight: FIXED

This was right, and my earlier rebuttal of the same theme was right about REPLACE and incomplete about MERGE. Worth stating plainly: in round 2 I reproduced the replace path, found the escape closed, and treated that as a rebuttal of the finding rather than of one of its two paths.

Reproduced on merge. _do_merge calls the same pre-flight as replace and then had no use-time re-check. With workspace replaced by an external symlink immediately after the pre-flight, merge created the tree through the link and wrote FOUR of the operator's memory files outside the data home -- knowledge/kb.sqlite3, memory/preferences.md, memory/projects.md, memory/history/2026-01-01.md -- and printed "Merge complete."

The per-file screens cannot catch this, which is the part worth recording: every final component is a fresh regular file, and a by-name open does not examine its ancestors, so O_NOFOLLOW per file is satisfied while the whole tree lands somewhere else. Refusing on the ROOT is what closes it.

Fixed by giving merge the same use-time root re-check replace already had: a root that fails the check now failed AFTER the pre-flight cleared it, so it is refused with a message saying so. Verified: rc=1, nothing written outside, the planted external file intact. Mutation-verified: removing the guard makes the new regression test fail with "merge reported success while writing outside the data home".

Round 5 finding 1 -- the residual mkdir race: ESCALATING, not patching

The remaining window is real and narrower: my check runs immediately before dd.mkdir(parents=True), so a swap landing between those two statements is not covered. A by-name check cannot close that; only anchoring the write to a descriptor can, which is what the finding's own fix line says.

Three measured facts about scope:

  1. dd.mkdir(parents=True, exist_ok=True) followed by _copy_tree_no_overwrite(sd, dd, ...) is on the MERGE BASE, in _do_merge, unchanged by this PR. The same shape is used there for skills as well.
  2. This PR's memory-tree branch follows that existing convention and then adds a use-time root re-check on top -- protection no other destination write in that function has.
  3. The base has no descriptor-anchored destination writes in the restore path to follow as a pattern.

So the prescribed subtraction -- revert the memory-tree merge branch until writes are anchored to a data-home descriptor -- removes a guard the rest of the function does not have, leaves the identical skills and workspace windows in place, and makes the file strictly less safe than it is now. The finding identifies a pre-existing, repo-wide property of the by-name restore path, not something introduced here.

Anchoring every destination operation in restore to an mc descriptor is worth doing and I am not arguing against it. It is an architectural change across the whole restore path, it belongs in its own change with its own review, and doing it inside a PR that has already been reshaped by a shrink is how a fix becomes a regression. Escalating that scope call to the maintainer rather than deciding it unilaterally.

Round 5 finding 2 -- refuse value-writing UPDATE triggers: REBUTTED, measured twice

This prescription has now been raised four times and measured twice, both times against the tree as it then stood. Refusing every non-FTS UPDATE trigger breaks three existing tests -- test_a_trigger_that_only_writes_values_is_still_handled_by_the_fixpoint, test_a_trigger_copying_the_old_value_is_still_cleaned, test_a_database_that_never_settles_is_refused -- and refuses the product's own external-content full-text index, whose maintenance is exactly such a trigger. It replaces "clean the propagated value" with "refuse to back up at all", so the operator goes from a correctly scrubbed bundle to no bundle.

What DID come out of this theme is in the tree: the narrow half is now implemented. A write-only UPDATE trigger whose body contains INSERT OR REPLACE or REPLACE INTO is refused, because those delete the conflicting row and insert in its place -- reproduced both spellings clobbering a row in an unrelated table -- and because they are spellings rather than effects, so text bounds them honestly. Verified it discriminates: both REPLACE forms refuse, a plain value-copying trigger is still cleaned by the fixpoint. That closes two of the three cases the refusal's docstring previously listed as residual, and the docstring now says which one is left and why it needs effect analysis.

I am not implementing the broad form. If the maintainer prefers it despite the measured cost, that is their call to make explicitly.

State

Linux: 682 passed, 0 failed. subprocess-encoding, black, isort, flake8, mypy (1127 files), docs-lint all clean. Backend Tests (Windows) shards 1, 3 and 4 passed on 4d628f6fe, the head carrying the Windows fix -- shard 4 is the one that had been red, and shard 2 was cancelled by a subsequent push rather than failing.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Opus 4.8 round on 4d628f6fe -- verdict was no-blocking with one FINDING. Taken, and it turned out to interact with a change of mine. Also recording a Windows regression I introduced and fixed, because it was mine and the way I caused it is worth naming. Head 2bca02a09.

Case-fragile FTS5 detection: TAKEN

Reproduced exactly as described. The detector was "USING fts" in sql.replace("using fts", "USING fts"), which folds only the all-lowercase spelling. SQLite stores DDL verbatim and its own documentation writes USING FTS5(...), so a table created the documented way was not recognised as full-text at all -- its WITHOUT ROWID shadow tables were then scanned as ordinary tables and the pass refused.

Measured both spellings against the same payload:

lowercase `using fts5`  -> redacted OK
UPPERCASE `USING FTS5`  -> PayloadDatabaseUnprovable: memory.db (docs_fts_idx: no such column: rowid)

So a sound database lost its entire off-host backup to a spelling. Fixed by lowering once and testing against that, with the contentless probe lowered alongside it as the finding asked.

Two things worth adding to the record.

First, this interacted with my own change. The refusal surfaces from the pager's SELECT MAX(<handle>), which is the per-pass ceiling I added earlier in this review to stop a row-inserting trigger from making a single pass diverge. That ceiling widened this pre-existing case bug's blast radius from "shadow tables scanned pointlessly" to "the whole backup refused". The case fix is still the right fix, but the finding was easier to reach because of me.

Second, the regression test pins the half a naive case fix would lose. Making detection case-insensitive is easy to get wrong in the direction of no longer recognising content='', which would trade a false refusal for a real leak -- a contentless index has no content table to regenerate from, so skipping its storage would ship a credential unexamined. So there are two tests: an uppercase USING FTS5 table now redacts, AND an uppercase CONTENT='' declaration is still refused. Mutation-verified by restoring the lowercase-only fold, which fails on the original no such column: rowid.

A Windows regression I caused, and how

Backend Tests (Windows) (4) went red on c93a80a9b after passing on 4d628f6fe. One failure, and it was the merge regression test I had added in that same push: its snapshot_main call was missing unpinnable_argv(), so on a platform with no directory descriptors it died at the staging refusal instead of reaching its subject.

The mechanism is worth stating plainly because it is not a typo. Every other call in that file carries the flag, because those were fixed by an oracle-driven pass over the whole surface. I wrote this test after that pass finished and verified it on Linux only -- the exact "verify BOTH platforms" step I had written into the instructions for that pass. A local green is not a platform green, and I had the tool to check and did not run it.

The fix also had to reach the restore_main call, not just the snapshot one: without the flag there, the merge would have been refused for the PINNING reason on that platform and the test's rc != 0 would have passed for the wrong reason -- green while proving nothing about the destination-root check it exists for. That failure mode is worse than the red it replaces, so it is called out in a comment at the call site.

Verified on both platforms this time: Linux 684 passed, and the simulated unpinnable platform leaves only the six tests that carry an os.name == "nt" skip and therefore do not run on the real shard.

Not from this diff

Frontend Coverage Merge on src/i18n/style/hiStyle.test.ts (#5843) is red on this branch's base, and this diff contains zero frontend files.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round on 2bca02a09 -- the generated-column finding is REAL and is fixed. Head 9f63c67c2. Confirming it took three steps, and the first two would each have produced a wrong answer, so the path is worth recording.

Generated columns bypassing redaction: FIXED

The stated mechanism is exactly right. PRAGMA table_info omits GENERATED columns, so the row pass never enumerates one; and a generated column is derived and READ-ONLY, so even seeing it would not let the pass rewrite it. That is why the fix is a refusal, not a redaction.

Step one would have said the finding is wrong. A STORED and a VIRTUAL generated column each carrying a credential were both refused, so nothing shipped. On that evidence the stated consequence -- the off-host database retains the credential -- does not hold.

Step two was asking WHY it refused, because refused-by-design and refused-by-accident are different answers and only one of them closes a finding. The cause was _SchemaCarriesCredential: a separate guard that scans the DDL text, catching the literal credential inside the generation expression. By design, not incidental -- but it also means the guard that saved the probe was not the column path at all.

Step three found the shape neither guard covers. Two guards stand between a generated column and an egress leak: the schema scan catches a credential written as a literal in the expression, and a STORED column is recomputed when its source is UPDATEd so redacting the source propagates. Neither covers a credential ASSEMBLED across columns:

CREATE TABLE t(id INTEGER PRIMARY KEY, a TEXT, b TEXT,
               joined TEXT GENERATED ALWAYS AS (a || b) STORED);
INSERT INTO t(id, a, b) VALUES(1, 'AKIA', 'IOSFODNN7EXAMPLE');

table_info reports only id, a, b. Neither column value matches a credential pattern. The DDL contains no credential. Nothing was refused, and the generated column materialised the whole key into the copy that leaves. So the finding is real, and stopping at step one would have shipped it.

Fixed with the prescribed remedy: read PRAGMA table_xinfo, and refuse when a generated column's value carries a credential, naming the table and column.

The scoping matters as much as the check. It is restricted to hidden flags 2 and 3 -- VIRTUAL and STORED generated -- and deliberately does not touch flag 1, which is what a virtual table's own columns present as. Sweeping flag 1 in would refuse every database with an FTS table, turning a hardening into an outage for exactly the backups this feature exists to take. Three tests pin that: the assembled credential refuses, a generated column holding nothing sensitive does NOT refuse, and an FTS table still redacts normally. Mutation-verified by disabling the guard, which fails on DID NOT RAISE.

The write-only trigger finding

Raised again, same broad prescription. Already dispositioned twice with measurements and escalated to the maintainer in the round-4/5 comment above; nothing has changed on my side since. The narrow half that IS implementable -- refusing a write-only UPDATE trigger whose body contains INSERT OR REPLACE or REPLACE INTO -- is in the tree and verified to discriminate. The broad form breaks three existing tests and refuses the product's own external-content index, so it needs an explicit maintainer decision rather than a sixth restatement from me.

State

Linux 687 passed, 0 failed. All gates clean. On the simulated unpinnable platform only the six tests carrying an os.name == "nt" skip remain, and those do not run on the real shard -- Backend Tests (Windows) shards 1, 3 and 4 have passed on the two most recent heads that reached them.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Both findings on 9f63c67c2 are correct and are fixed on 3a4b8529a. Both landed on the helper added one push earlier, and both are the same kind of mistake: new code that did not carry over a convention this module had already established. Worth stating plainly, since that is the exact shape of several findings dispositioned earlier in this PR.

BLOCKING -- generated BLOB credentials bypass the scan: FIXED

isinstance(value, str) was the wrong filter. sqlite stores plain UTF-8 text as a BLOB routinely, and the row pass a few hundred lines below already handles byte-valued columns for that reason -- the new helper simply did not follow it.

Fixed by scanning bytes and bytearray as well. The row pass splits text-shaped bytes (rewrite) from structured bytes (refuse); this helper deliberately does not, because that distinction exists to decide whether a value can be safely REWRITTEN and a generated column is read-only. Every hit ends the same way here: refuse.

One detail worth recording, because the first probe of this would have produced the wrong conclusion. a || b over two blobs yields TEXT -- typeof() says text and the driver hands back a str -- so every "BLOB" case built with || was already caught by the old str-only filter. On that evidence the finding looks theoretical. CAST(a || b AS BLOB) is the form that actually stores bytes, confirmed by typeof() returning blob and the driver returning a bytes, and that case DID ship a usable credential before this fix. So the finding is reachable, not defensive -- but only via a form the obvious probe does not produce.

The regression test uses the CAST form and asserts typeof(joined) == "blob" before asserting the refusal, so if sqlite's behaviour ever changes the test reports that it no longer stores bytes instead of quietly passing as a no-op. Mutation-verified: restoring the str-only filter fails on DID NOT RAISE.

BLOCKING -- generated-column scan can exhaust memory: FIXED

fetchall() on a generated column materialises as many rows as the table has, which is how a backup becomes an OOM kill. Same shape as above: this module already pages the row scan for exactly this reason.

Fixed by iterating the cursor. No page window is needed here, unlike the row pass -- the question is all-or-nothing, so the loop stops at the first match and never holds more than one row.

State

Linux 688 passed, 0 failed; all gates clean. The elif isinstance(...) site in the row loop is untouched -- the diff is a single hunk inside the new helper.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Both findings on 3a4b8529a are real, reproduced, and fixed on a806e9762. Both are the same shape as several earlier findings on this PR: a rule this module had already stated for one case, never carried to the neighbouring one.

BLOCKING -- stale FTS credentials bypass redaction: FIXED

Correct, and the code already contained the argument against itself. The comment on the unconditional VACUUM says gating on hits "would protect only the rows this pass rewrote" -- and the FTS rebuild twelve lines above it was still gated on hits. The reasoning was written down and applied to one of the two operations.

An external-content FTS index keeps its own tokenized copy and does not auto-sync, so a base table can move on and leave the index holding text no live row contains. The scan then reports hits == 0 -- correctly; the rows ARE clean -- and no rebuild runs. VACUUM does not cover it either: it repacks live content and never re-tokenizes, and the stale doclist is live content of the shadow table.

Reproduced: base row updated to drop the credential without syncing the index, no refusal, and the egress copy still answered docs_fts MATCH '<key>' with a hit after a full pass.

One measurement detail, because the first attempt at it produced a FALSE clean. Checking the file bytes for the credential in its own casing reported nothing present -- FTS5's default tokenizer LOWERCASES terms, so the index copy is not stored in the credential's casing. The database was still fully searchable for the key. The regression test therefore asserts through a MATCH query and on the lowercased bytes; asserting only the original casing would have passed against the unfixed code.

Fixed by rebuilding every identified index unconditionally, as prescribed. Safe without a new guard because a CONTENTLESS index -- the one shape with no content table to rebuild from -- is already refused before this point, so every name in the list has a source. Mutation-verified.

The manifest's indexes_needing_rebuild still lists only indexes rebuilt alongside a real replacement; the unconditional pass is hygiene on the throwaway egress copy, like the VACUUM, which is likewise not itemised. Flagging that choice explicitly in case a reviewer wants it reported unconditionally instead.

BLOCKING -- replace retains the previous memory index: FIXED

Also correct, and the justification for the behaviour was mine and was wrong. The redaction pass DROPS memory_index.db from an off-host bundle, and the comment defending that drop says restore "handles an absent index by telling the operator to rebuild it". That is true only when the live index is absent too: the warning tests the file AFTER the restore, so a surviving stale index means it never fires. A restore from a redacted bundle -- the ordinary case -- therefore ended with new memory and an index built from the old, silently.

Reproduced against _do_replace_mutations directly: memory.db becomes NEW-MEMORY while memory_index.db still reads INDEX-OF-OLD-MEMORY.

The memory-TREE loop in the same function already states the rule -- "a tree the archive does not have is a tree the destination must not keep" -- and its comment records that clearing only when the archive had it once produced "restored memory mixed with stale notes" that still reported success. A derived index is a FILE and never got the same treatment.

Fixed as prescribed: in replace mode a derived index the archive omits is moved into the rollback set and removed, so the absence is real and the existing warning fires. Two details follow the discipline established earlier in this PR -- the name enters the installed ledger immediately before the move and never earlier, since the move IS the save the recovery leg needs; and a junction is removed with the link-aware helper rather than moved. Scoped to derived indexes on purpose: a missing payload database is a different question whose answer is refuse, not delete.

The set naming those indexes could not be imported from the redaction module -- it is loaded lazily to stay out of the boot path that test_perf_boot_path.py guards -- so it is duplicated, and a test asserts the two sets agree. A future divergence fails loudly instead of silently restoring a stale index.

State

Linux 693 passed, 0 failed. All gates clean. On the simulated unpinnable platform the same six os.name == "nt" skip artifacts remain and none of the new tests is among them.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Findings on a806e9762. One is fixed on 3eed3e4cc. The other has the wrong mechanism, but measuring it turned up a REAL leak next to it that I am escalating rather than patching here -- details below, because the difference matters.

BLOCKING -- UPDATE triggers can corrupt unrelated backup data: FIXED

This is the sixth time the trigger theme has been raised, and the first time it has arrived with a consequence my earlier measurements do not answer. The previous rounds argued credential SURVIVAL, which the fixpoint handles. This one argues data LOSS: the redaction's own UPDATE fires a trigger that overwrites unrelated rows, and the copy that is uploaded -- and later restored -- carries altered data. Different claim, and it is correct.

Reproduced: UPDATE unrelated SET note='CLOBBERED' on a trigger for the redacted table. No refusal, and the operator's row read CLOBBERED afterwards.

Fixed, but NOT with the prescribed rule, because I measured the prescription and two variants of it against every shape this suite pins:

trigger body broad "non-FTS UPDATE-writing" "body contains UPDATE" shipped: by TARGET
UPDATE unrelated SET ... (foreign table) refuse refuse refuse
UPDATE t SET mirror=NEW.body WHERE id=NEW.id on t refuse -- WRONG refuse -- WRONG allow
INSERT INTO audit VALUES(OLD.body) refuse -- WRONG allow allow
product's external-content FTS maintenance refuse -- WRONG allow allow

The broad rule fails three of four; "any UPDATE in the body" fails the mirror column, which is an ordinary shape the fixpoint provably cleans. Keying on the TARGET -- an UPDATE aimed at a table other than the trigger's own -- refuses the reproduced case and leaves the other three working. That is what shipped, with all four pinned as tests so the rule cannot be widened later without the failures showing up.

Stated residual, narrower than before: an UPDATE on the trigger's OWN table with no row bound can still overwrite other rows of that table. Text cannot separate that from the mirror column; it needs the statement's effects.

BLOCKING -- SQL views bypass the credential scan: mechanism is WRONG, but there is a real leak beside it

The view is not the mechanism. Four cases, measuring whether the whole key is present in the FILE BYTES after a full pass:

case key in bytes after
A) view over a,b + adjacent columns yes
B) NO view, same adjacent columns yes
C) NO view, a third column BETWEEN the two halves no
D) one column holds the whole key (control) no -- cleaned

B leaks identically to A, so removing views changes nothing; C does not leak, which identifies the actual cause. SQLite stores a row's cells CONTIGUOUSLY, so a='AKIA' immediately followed by b='IOSFODNN7EXAMPLE' writes the whole key into the page as a storage artifact. No view, no generated column, no DDL literal. The value scanner cannot see it because no single value matches.

So the prescribed fix -- refuse databases containing views -- would refuse ordinary databases and would not close case B. I am not applying it.

The leak itself is real and I am NOT claiming otherwise. What it is not is a property of this PR's new code: it is inherent to scanning VALUES, which is how any row-level redaction works. The only remedy I can see is to scan each row's concatenated cells and REFUSE on a match, and refusing cannot be judged from a probe -- it needs measuring against real operator databases first, because a false positive refuses a legitimate backup outright. An AWS-key-shaped match is a 20-character alphanumeric run, which two innocent adjacent values can produce.

Escalating it for that reason, in the same position as the merge mkdir race: real, worth its own change, and not something to land unmeasured in a converging PR. Recorded here so it is not lost.

State

Linux 696 passed, 0 failed. All gates clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Finding on 3eed3e4cc -- the same-table exclusion. The residual it names is real; I documented it myself one push earlier. The prescribed fix is measurably wrong. Closed on e7f663603 with a narrower rule that gets both. Head e7f663603.

The residual was real

UPDATE items SET note='CLOBBERED' names the trigger's OWN table, so a rule keyed only on the target let it through -- and with no row bound it rewrites every row of the table it fires on. That is the same corruption as the foreign-target case, reachable a different way. Reproduced.

The prescription is not the fix

"Remove the same-table exclusion" refuses this instead:

CREATE TRIGGER copyval AFTER UPDATE ON t BEGIN
  UPDATE t SET mirror = NEW.body WHERE id = NEW.id;
END;

A mirror column. Ordinary shape, and the fixpoint provably cleans the propagated value -- test_a_plain_value_writing_trigger_is_still_cleaned_not_refused asserts exactly that, with the stated rationale "refusing this would discard the fixpoint". Removing the exclusion turns a backup this pass handles correctly into a refused upload.

So the target is the wrong axis to widen. The right one is whether the statement is BOUND to the row that fired it:

trigger body remove exclusion shipped: row-bound test
UPDATE unrelated SET note='CLOBBERED' (foreign) refuse refuse
UPDATE items SET note='CLOBBERED' (own, unbound) refuse refuse -- the finding
UPDATE t SET mirror=NEW.body WHERE id=NEW.id (own, bound) refuse -- WRONG allow
INSERT INTO audit VALUES(OLD.body) allow allow
product's external-content FTS maintenance allow allow

"References NEW or OLD" is the bound. It is a spelling rather than an effect, which is what makes it honest for text to decide -- the same standard already applied to INSERT OR REPLACE and REPLACE INTO on this path. All five shapes are pinned as tests, so the rule cannot be widened or narrowed later without a failure showing up. Mutation-verified: allowing every same-table update fails the new test on DID NOT RAISE.

Residual, stated

A statement can reference NEW and still touch other rows deliberately -- WHERE id != NEW.id. That is an adversarial construction, and separating it from the mirror column needs the statement's effects rather than its text. The accidental corrupting trigger is the unbound form, which is now refused. This is the fourth successive narrowing of this guard, and each step has been pinned by the cases it must not break.

State

Linux 697 passed, 0 failed. All gates clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Findings on e7f663603. One is fixed on 4883fb07c; the other is the seventh restatement of a theme already escalated, with the prescription I measured and refuted one push earlier.

BLOCKING -- interrupt bypasses replace rollback: FIXED

Correct, real, and the handler's own comment already contained the argument for it. That comment explains why PinnedPathRefusal had to be added to the caught tuple: it fires MID-mutation, so leaving it out "left live state half replaced". KeyboardInterrupt has precisely that property and is not an Exception at all, so it walked past the handler entirely.

Reproduced: a Ctrl-C injected after the memory component was replaced left memory.db holding the archive's copy while crons.json was still the live one, with no rollback attempted. Live state half swapped, which is the single condition the phase-two rollback exists to prevent.

Fixed as prescribed -- catch BaseException, roll back, re-raise. Both halves are asserted by the regression test, because rolling back is only half the requirement: the interrupt must still terminate the command rather than be absorbed into a success. Mutation-verified by narrowing the tuple back, which fails on "rollback did not run".

Two limits worth stating rather than implying. A second interrupt DURING the rollback cannot be defended against from here; the rollback directory is what answers for that. And when the rollback itself fails the original exception is still replaced by RollbackIncomplete, including for an interrupt -- telling the operator that some previous state now exists only in the rollback directory matters more than preserving the exception's identity.

BLOCKING -- same-table trigger exemption: unchanged, and already escalated

Seventh raising of this theme, and the first with no change in either the prescription or the consequence from the previous round. "Remove this exemption so own-table UPDATE triggers are refused" is the same instruction I measured one push ago, and "overwrites sibling rows" is the WHERE id != NEW.id residual I stated explicitly in that disposition rather than leaving implied.

The measurement has not moved: removing the exemption refuses

UPDATE t SET mirror = NEW.body WHERE id = NEW.id;

a mirror column, which test_a_plain_value_writing_trigger_is_still_cleaned_not_refused asserts must keep working, with the rationale "refusing this would discard the fixpoint". Four successive narrowings of this guard have landed, each pinned by the cases it must not break; this step would trade a real, common shape for an adversarial one.

I am not applying it and I am not re-arguing it an eighth time. It needs a maintainer decision, which is where it already sits -- if the call is that refusing mirror columns is acceptable, say so and I will implement it with the fixpoint tests updated to match. Until then the residual stands as documented in the function's own docstring.

State

Linux 698 passed, 0 failed. All gates clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Findings on 4883fb07c. The first is real, was made worse by my own change two pushes earlier, and is fixed on 02d974a8f. The second is the eighth restatement of a theme already escalated.

BLOCKING -- manifest-only memory bundles partially erase live memory: FIXED

Correct, and it lands on a gap in MY reasoning. When I added the derived-index removal I wrote that a missing payload database "is a different question with a different answer (refuse, not delete), and it is answered elsewhere". It is not answered on this path -- and my change added a third thing that gets erased in this scenario, so it widened the damage rather than being merely adjacent to it.

Two measurements decided how seriously to treat it.

What replace leaves behind, reproduced: memory trees CLEARED (unconditionally, by the rule that a tree the archive lacks must not be kept), derived index REMOVED (my change), and memory.db KEPT, because _backup_and_copy has no copy to install and skips it. The database survives and the notes indexed against it do not. A partial erasure is worse than either extreme.

Whether the product can produce such a bundle -- this is what moved it from defensive to live. It can: a snapshot of a home with no memory payload declares memory anyway, along with every other component, and carries no memory files. So restoring an ordinary product-written bundle onto a home that HAS memory is the path, not a hand-crafted manifest.

Fixed as prescribed: a DECLARED component the bundle carries no non-derived payload for is refused. The explicit-selection branch a few lines below already refused exactly this situation, with the rationale that replace "would move the live files of that component out to the rollback dir and have nothing to put back" -- the same question had two different answers and only one branch was guarded. A derived index does not count as payload, since a bundle carrying only memory_index.db still has no memory to restore.

The payload test derives what counts from COMPONENTS rather than a hand-written list, so a component that gains a file later cannot silently start passing on a stale enumeration. Both files and trees count, so a component whose data is a directory is not called hollow for keeping no flat file.

One note on verification, because it changes what the evidence is worth. My first probe drove _do_replace_mutations directly and still showed the partial erasure AFTER the fix -- the guard is at the decision point, and calling the mutation phase bypasses it. The regression test therefore goes end to end through restore_main on a bundle the product itself wrote, and asserts the refusal happens BEFORE anything is cleared: all three live artifacts are still byte-identical afterwards. Mutation-verified.

BLOCKING -- NEW/OLD does not prove a row-bounded update: unchanged, already escalated

Eighth raising. The case it names -- WHERE id != NEW.id -- is the residual I stated explicitly in each of the last two dispositions rather than leaving implied, and the prescription is the same one measured harmful each time: removing the exemption refuses

UPDATE t SET mirror = NEW.body WHERE id = NEW.id;

which test_a_plain_value_writing_trigger_is_still_cleaned_not_refused asserts must keep working. The finding is right that a NEW reference is not proof of a row bound. It is the remedy that has no acceptable form yet: text cannot separate the two, so closing it means either refusing mirror columns or reading the statement's effects.

Not applying it, not re-arguing it a ninth time. It needs the maintainer call it is already waiting on -- if refusing mirror columns is acceptable, say so and I will implement it and update the fixpoint tests in the same change.

State

Linux 700 passed, 0 failed. All gates clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Correct, and it is the half I missed one push ago. Fixed on 86b48d8aa.

BLOCKING -- explicit selections trust hollow components: FIXED

The previous fix guarded the manifest-derived branch and left the explicit-selection branch testing c not in declared -- membership in the declaration, not whether any payload rode. A bundle can declare memory and carry none of it, so --components memory --mode replace sailed through the guard written for precisely this situation.

What makes this worth stating plainly: my own disposition for the previous round said "the same question had two different answers and only one branch was guarded", and then I fixed one branch with a payload test while leaving the other on the weaker membership test. Same defect, one layer down, authored by the fix for it.

Reproduced, and the reproduction is more useful than the description. Driving it end to end showed rc == 1 ALREADY, before any fix -- not from a refusal, but from the memory.db integrity check failing later in the run, by which point the live memory trees were already cleared. So an assertion on the exit code alone would have passed both before and after the change and proved nothing. The regression test therefore asserts the operator's note SURVIVES as its load-bearing check, tests it BEFORE the exit code, and gives the destination a real database so the integrity check cannot decide the outcome. That ordering is the part worth copying, not the fix.

Both branches now ask the same question: a component is absent if the bundle does not declare it OR carries no non-derived payload for it.

State

Linux 701 passed, 0 failed. All gates clean. The trigger exemption remains where it was left -- eight raisings, awaiting the maintainer call, not re-argued here.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Both findings on 86b48d8aa are real and both are fixed on 33fbb47fa. Neither is fixed with the prescribed remedy: implementing each literally broke tests that assert a guarantee, so both were narrowed to the property that actually distinguishes the defect. The measurements are below, since "we applied your fix and it broke four tests" is the useful part.

BLOCKING -- NUL-separated credentials bypass outbound redaction: FIXED

Correct, and precisely diagnosed. UTF-16LE with no BOM decodes as valid UTF-8 -- NUL is a legal codepoint -- so read_text succeeds, the text reads A\x00K\x00I\x00A..., no credential pattern matches across the NULs, _scrub returns 0 hits, and the file is reported handled while the credential rides out intact. Reproduced: no refusal, credential still present.

The check that would have caught it already existed, one branch too deep. Inside if hits: it only ever guarded against CORRUPTING a NUL-bearing file that did match -- it could never see a file whose NULs prevented the match.

Treating NUL-bearing decoded files as opaque, as prescribed, is wrong. Measured: it fails test_a_nul_bearing_file_with_no_credential_still_rides, which plants an ordinary tar inside the workspace and asserts it rides, with the stated reason "the hazard is on the WRITE branch, so refusing this one would cost an upload". A workspace routinely holds binary blobs; that rule refuses real backups over files carrying nothing.

Shipped instead: when NULs are present, the UTF-16LE and UTF-16BE INTERPRETATIONS are scanned, and only a credential actually found there refuses. A hit cannot be rewritten -- the real encoding is unknown and a replacement of a different length shifts every byte after it -- so opaque is the outcome, which refuses the upload and names the file. Both encodings and the untouched-binary case are pinned as tests; mutation-verified.

BLOCKING -- rejected archive links trigger live-state deletion: FIXED, at a different layer

The consequence is real: extraction PRINTS a warning and drops a rejected entry, then continues, so a bundle can reach the restore missing part of its payload while the manifest still declares it. In replace mode the memory trees are cleared unconditionally and nothing refills the dropped one.

The prescribed fix -- clear d only when sd.is_dir() -- is wrong twice over. First, it reverts a deliberate guarantee: clearing only when the archive carries the tree is the exact defect a documented fix removed, which produced "restored memory mixed with stale notes" reported as success. Second, it cannot work, because at that layer the two cases are indistinguishable -- measured, a rejected link and an archive that never carried the tree both present as "staged tree absent", byte for byte.

Extraction is the only layer that knows the difference, so the refusal went there. And it is scoped: a rejection is recorded only when the entry lies inside a tree replace CLEARS (workspace, plan_memory, skills), because that is where dropping an entry converts into DELETION rather than mere absence. Refusing on any rejection at all breaks test_symlink_filtered_out, test_path_traversal_filtered and test_absolute_path_filtered, which state the opposite contract explicitly: a hostile entry injected into a sound bundle is dropped and the restore SUCCEEDS.

One test needed updating and it is worth being explicit that no guarantee was weakened. test_the_bound_runs_before_extraction_on_the_local_path located the extraction site by the literal string tar.extractall(work, filter=_data_filter); passing a wrapper renamed that argument. The assertion it exists for -- the archive-size bound runs BEFORE extraction -- is unchanged, and the locator is now a structural match on the call rather than its argument list, so the next rename does not fail it either.

State

Linux 704 passed, 0 failed. All gates clean.

Shrunk to the two things the AWS Control app does not do: restoring a
snapshot into live state (replace/merge, two-phase backup-before-remove,
rollback ledger, saved-link reinstatement) and redacting the outbound
copy before it is pushed.

The destination, its hardening, the consent grant, the transport, session
backups and the schedule now belong to that app, so snapshot_remote.py,
backup_cli.py, the --to-s3 and --aws-profile flags and the s3:// fetch
path are deleted here rather than shipped twice. prepare_redacted_copy
is the destination-free seam the app calls before authorizing an upload,
so a redaction that cannot complete stops the push instead of falling
through to sending unredacted.
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Findings on 33fbb47fa. The first is a correct catch that my previous fix left half-done; it is closed on 1655efe08. The second is the mkdir race, whose prescription is a revert I have measured.

BLOCKING -- encoded credentials still bypass the scan: FIXED

Both halves are right, and the UTF-32 half is the sharper one because it explains why the previous fix was not enough on its own: read as UTF-16LE, a UTF-32LE credential is STILL NUL-separated (A\x00 then \x00\x00), so it survived the UTF-16 scan for exactly the reason it survived the original one. Covering UTF-16 and stopping there was the same mistake one layer up.

The column path had the identical hole. It decodes latin-1, which is lossless byte-to-codepoint and therefore PRESERVES the NUL spacing, so its zero-hit result was not evidence of a clean value either.

Both now go through one shared detector over UTF-16LE/BE and UTF-32LE/BE, rather than two copies that can drift -- that drift is what produced this finding. A hit can only REFUSE, never rewrite: the real encoding is a guess, and a replacement of a different length shifts every byte after it. The file path returns the file to opaque; the column path raises the table-level refusal it already uses for bytes it cannot rewrite.

Measured on four shapes, with the discrimination halves pinned as tests:

case outcome
UTF-32LE file refused
UTF-16LE BLOB in a column refused
ordinary binary blob, no credential NOT refused
plain ASCII credential in a column still REDACTED in place, not refused

The last two matter as much as the first two: refusing them would trade a credential leak for an outage on ordinary backups. Mutation-verified by dropping UTF-32 from the encoding list, which fails on DID NOT RAISE.

BLOCKING -- forced restore retains an ancestor-symlink race: unchanged, already escalated

This is the mkdir race, and the prescription is now explicitly "revert the memory-tree restore additions". Measured when it was first raised, and the measurement is why I have not applied it: dd.mkdir(parents=True, exist_ok=True) followed by _copy_tree_no_overwrite(sd, dd, ...) is ON THE MERGE BASE in _do_merge, used for skills as well, and the base has NO descriptor-anchored destination writes to revert to. So the revert removes the safe_tree_root check -- the only guard in that function -- leaves the identical base windows open, and makes the file strictly less safe than it is now.

The race is real. Closing it properly means anchoring every destination write to a descriptor opened once, which is an architectural change to the by-name restore path, repo-wide rather than specific to this PR, and it belongs in its own change with its own tests. That is the maintainer decision it is already parked on. Not applying a revert that measurement says is a regression.

State

Linux 707 passed, 0 failed. All gates clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Findings on 1655efe08. No code change this round, and the measurement is why.

BLOCKING -- expression indexes bypass redaction: the index is a BYSTANDER

An expression index does materialise, so this was worth taking seriously -- materialisation is exactly what made the generated-column finding real while the view finding was not. Measured on three shapes, asking whether the whole key is in the FILE BYTES after a full pass:

case key in bytes after
A) expression index on (a || b), credential split across a and b yes
B) the same split columns with NO index at all yes
C) plain index, one column holding the whole credential no -- cleaned

A and B are identical, which settles it: the expression index adds NOTHING over the leak that is already open without it. SQLite stores a row's cells contiguously, so a='AKIA' immediately followed by b='IOSFODNN7EXAMPLE' writes the whole key into the page whether an index exists or not. That is the adjacent-column leak I raised myself two rounds ago and escalated for a decision; this finding is a second route to the same place, not a new one.

C is the other half of the answer, and it shows the ordinary path is sound: when the credential IS a value the scanner can see, the row rewrite plus SQLite's own transactional index maintenance plus the VACUUM removes it from the index pages too. So there is no stale-expression-index case to fix -- unlike external-content FTS, which is documented as not auto-syncing and which is why that one was a real defect.

The prescribed remedy would therefore refuse ordinary databases that carry an expression index while leaving case B -- the same credential, same bytes, no index -- completely untouched. Not applying it.

What IS real here remains what I escalated: a credential split across adjacent columns is invisible to any value-level scan, and the only remedy is to scan each row's concatenated cells and REFUSE on a match. That cannot be judged from a probe, because an AWS-key-shaped match is a 20-character alphanumeric run that two innocent adjacent values can produce, and a false positive refuses a legitimate backup outright. It needs measuring against real operator databases, which is a decision rather than a patch.

BLOCKING -- NEW/OLD does not prove a row-bound update: ninth raising

Same prescription, same case (WHERE id != NEW.id), same measurement: removing the exemption refuses UPDATE t SET mirror = NEW.body WHERE id = NEW.id, which test_a_plain_value_writing_trigger_is_still_cleaned_not_refused asserts must keep working. The finding is correct that a NEW reference is not proof of a bound; the remedy is what has no acceptable form, since text cannot separate the two.

Waiting on the maintainer call, unchanged. If refusing mirror columns is acceptable, say so and I will implement it and update the fixpoint tests in the same change.

State

Linux 707 passed, 0 failed. All gates clean. Nothing was changed on this head, so the previous verification still stands.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/ai-review override gpt 1655efe: Both measured; neither remedy applies. Expression index: a control with NO index leaks identically (SQLite stores row cells contiguously), so the index is a bystander; refusing them rejects ordinary DBs and leaves the same bytes exposed. The split-credential leak itself is escalated. Trigger: 9th raising; removing the exemption refuses UPDATE t SET mirror=NEW.body WHERE id=NEW.id, which test_a_plain_value_writing_trigger_is_still_cleaned_not_refused pins as required.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 1655efe0870ca1b39de560a44d4406ebcd1ae8a2.

Both measured; neither remedy applies. Expression index: a control with NO index leaks identically (SQLite stores row cells contiguously), so the index is a bystander; refusing them rejects ordinary DBs and leaves the same bytes exposed. The split-credential leak itself is escalated. Trigger: 9th raising; removing the exemption refuses UPDATE t SET mirror=NEW.body WHERE id=NEW.id, which test_a_plain_value_writing_trigger_is_still_cleaned_not_refused pins as required.

This decision applies only to this commit. A new push requires a new judgment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

posix-only-approved Cross-Platform Portability findings reviewed and accepted as intentionally POSIX-only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants