Skip to content

feat(backup): a versioned manifest digests every class A file of an install (#731, slice 2) - #960

Merged
github-actions[bot] merged 4 commits into
mainfrom
fix/731-backup-manifest
Nov 10, 2026
Merged

github-actions[bot] merged 4 commits into
mainfrom
fix/731-backup-manifest

Conversation

@cdeust

@cdeust cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Summary

Second slice of #731 (first remaining slice listed in docs/audits/owned-resources-backup-2026-11-05.md §5): a versioned manifest naming the class A resources of an install, with a SHA-256 digest and a size for every file.

Refs #731 (not Closes: the consistent snapshot, the restore check, the refusal of an incompatible archive and the recovery documentation remain).

Root cause of the gap. No code defined what a Cortex backup carries. Slice 1 (#892) inventoried the files in prose only. A snapshot or a restore check has nothing to work from until the set of entries and their digests exist as data.

What this adds

  • mcp_server/core/backup_manifest.py (pure): the resource table, taken from the audit's class A set by backend:
    • sqlite: memory.db, required;
    • managed: cluster and password, both required, the password marked secret;
    • postgresql: no store entry, since the server is the user's (class X);
    • every backend: marker, wiki, artifacts, MCP connections, profiles, session log, capture spool.
      It also provides build_manifest, a canonical to_json, and a parse_manifest that refuses another format, another version or another table.
  • mcp_server/infrastructure/backup_scan.py: reads one resource under the root and digests each file (hashlib.file_digest). A symbolic link (dangling or not) and any special file are refused, not skipped: following a link would digest data outside the root, and skipping it would drop a file silently.
  • mcp_server/handlers/backup_manifest.py: the composition root backup_manifest(root, backend). No MCP tool is registered; later slices call it.
  • A required store that is absent is refused with the path named. A database moved out of the root by CORTEX_MEMORY_DB_PATH is therefore refused, not left out silently.
  • The -wal/-shm files are not entries. The manifest describes the tree it is given; producing a consistent copy is the next slice.

Type of change

  • New feature (non-breaking change which adds functionality)

Test plan

  • New tests: tests_py/core/test_backup_manifest.py, tests_py/infrastructure/test_backup_scan.py and tests_py/handlers/test_backup_manifest.py, 48 passed (pytest -q on the three files, Python 3.11.18, Linux).
  • Mutation gate, GITHUB_BASE_REF=main python scripts/ci_mutation_diff.py (mutmut 3.8.1): 3 file(s), 0 failed, 0 untested. A first run left two survivors, both equivalent: ensure_ascii=None behaves like False, and "SHA256" is accepted like "sha256". I removed their cause (default ASCII JSON; the hashlib.sha256 constructor instead of a name) rather than registering them.
  • python scripts/check_craftsmanship.py: OK. python scripts/check_doc_claims.py: OK. tests_py/architecture + tests_py/scripts/test_craftsmanship_layer_table.py: 19 passed.
  • ruff check / ruff format --check on the changed files: clean.

Not verified locally

  • The full suite, and the CI install resolved from uv.lock: torch cannot be downloaded in this environment, so I ran a minimal venv (pytest, pydantic, numpy, mutmut).
  • Windows: the symlink tests are skipped there, and the socket test needs AF_UNIX.
  • PostgreSQL-gated tests: no server here, but none of the new tests need one.

Audit notes

  • Source of every resource path: the audit's §2 table, which cites a line per path. The paths were re-read on fc13eb1: memory_config.py:51, profile_store.py:19-21, config.py:38-42, backend_marker.py:48, managed_pg.py:64-70 and capture_spool.py:60-62.
  • VERSION = 1 carries a # source: comment (first format of this slice, bumped on any change to the table or the fields), on the precedent of memory_portability.VERSION.

Coding-standards compliance

  • §2.2 core imports stdlib only (no os/pathlib); infrastructure imports stdlib only; the handler composes both.
  • §4.1 / §4.2: craftsmanship gate OK (300 lines per file, 40 lines per function).
  • §8 / §9: no unsourced constant, no TODO.

Breaking changes

None. These are new modules; nothing existing calls them.

Reviewer checklist

  • CHANGELOG.md updated (Added).
  • Documentation updated: docs/module-inventory.md, and audit §5 marks the slice delivered.
  • No secrets / credentials / PII in the diff.
  • CI passes on the latest commit (pending).

Generated by Claude Code

…nstall (#731, slice 2)

The manifest names the class A resources of docs/audits/owned-resources-backup-2026-11-05.md
by backend and gives a SHA-256 digest and a size per file. A required store that is
absent, a symbolic link or a special file is refused, never skipped.

Refs #731

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYt9sk9gsvUQZCwMbuF
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T07:46:13.400222Z aef8895 New commits
🔒 Security Review ✅ Completed 2026-11-10T07:47:18.543905Z aef8895 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5cc4524072

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +60 to +63
target = root / relative
if not target.exists() and not target.is_symlink():
return None
is_dir = stat.S_ISDIR(_checked_mode(target))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject symlinks in resource ancestor directories

When methodology or .capture-worker is a symlink, checking only the final resource with lstat() follows that ancestor link without detecting it. For example, with root/methodology pointing to an external directory containing memory.db, backup_manifest(root, "sqlite") successfully hashes the external database and labels it methodology/memory.db. This violates the scanner's explicit refusal to follow links outside the root; a dangling ancestor can also silently turn optional resources into absent entries. Check each path component below the supplied root before reading the resource.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in the next commit (fixup of 5cc4524). scan_resource now walks every directory between the root and the resource (_check_ancestors). A symbolic link there is refused, dangling or not, and so is a file standing where a directory is expected. The walk stops at the first absent directory, which makes the resource absent. Tests: test_a_symlinked_ancestor_is_refused (methodology -> outside/ holding a memory.db), test_a_dangling_symlinked_ancestor_is_refused_not_absent, test_a_deep_ancestor_that_is_a_file_is_refused, plus the two absent-ancestor cases.


Generated by Claude Code

Comment on lines +201 to +206
names = [entry.get("name") for entry in entries]
if names != [spec.name for spec in specs]:
raise ManifestError(
f"entries {names} do not match the {manifest['backend']} table"
)
return manifest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate resource metadata when checking the manifest table

A manifest with the expected entry names but different resource definitions passes this check unchanged. For example, changing the SQLite entry's path to /outside/other.db and its kind to dir is accepted by parse_manifest(), even though neither matches the versioned table. Entries containing only their names are accepted too. Consequently, the advertised rejection of another resource table does not work beyond checking names and order. Validate each entry's fixed metadata against its corresponding ResourceSpec before returning it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in the next commit (fixup of 5cc4524). After the name check, parse_manifest rebuilds each entry from its spec and from its own file records, then requires an exact match. This refuses: a different path, kind or secret; an extra key; files on an absent entry; a required entry marked absent; unsorted files; a record outside its directory; and malformed present/files/record types. Tests: test_parse_refuses_an_entry_that_differs_from_its_spec_or_files (your /outside/other.db + kind: dir example among them), test_parse_refuses_malformed_presence_or_files, test_parse_refuses_unsorted_files, test_parse_refuses_a_required_entry_marked_absent.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d8e1b8fc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +128 to +129
if spec.kind == "dir" and not path.startswith(spec.path + "/"):
raise ManifestError(f"{path} is not inside {spec.path}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject parent traversal in manifest file paths

When a directory entry contains a path such as methodology/wiki/../../outside.txt, the string-prefix check accepts it even though it resolves outside that resource. I verified that both build_manifest() and parse_manifest() accept this record unchanged with a valid digest and size; rebuilding the entry in _check_entry() does not catch it because it uses this same check. This violates the manifest's containment invariant and lets malformed manifests pass validation. Reject parent-traversal components and require canonical relative paths before checking resource membership.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in the next commit (fixup of 5cc4524). Before the containment check, every record path must now be a canonical relative POSIX path: no empty, . or .. component and no backslash (_is_canonical). Because this runs in _checked_files, build_manifest and parse_manifest both refuse methodology/wiki/../../outside.txt. Names that merely contain dots (..a.md, a..b/.c) stay valid. Tests: test_a_path_that_is_not_canonical_is_refused (7 cases), test_dots_inside_a_name_are_canonical and test_parse_refuses_a_parent_traversal_in_a_file_path.


Generated by Claude Code

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

PR #960 was updated after the trigger (5cc4524 -> cbe7a8b). Push again or mention Claude to run on the latest version.

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

PR #960 was updated after the trigger (2d8e1b8 -> cbe7a8b). Push again or mention Claude to run on the latest version.

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 15s —— View job


Review 1 of 3: PR #960

  • Read the diff and the project rules (CLAUDE.md)
  • Ran five reviews: code quality, security, performance, test coverage, documentation
  • Checked each finding against the Blocker rule and posted the review

The review is posted here: #960 (comment)

I found no Blockers. The three earlier Codex findings are all fixed.

The review lists three Notes, none of which need action before merge:

  • parse_manifest raises TypeError, not ManifestError, when backend is not a string. It also accepts "version": true or 1.0, and duplicate file paths.
  • The scanner can be raced between the lstat check and the open, and on a live file the recorded size and digest can describe different bytes.
  • The audit's §5 slice numbering does not match the code, and its memory_config.py line citations are off.

I could not run the tests or git fetch here: running commands needed approval this job does not have. The Notes come from reading the code.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review 1 of 3: PR #960 (commit cbe7a8b)

I found no Blockers. I ran five reviews (code quality, security, performance, test coverage, documentation). No finding makes CI, security or a CLAUDE.md non-negotiable wrong:

  • Layers: core imports the standard library only, infrastructure imports no project module, and the handler composes the two.
  • Size limits: every file is under 300 lines and every function under 40.
  • Unsourced constants: none the gate flags.
  • Earlier Codex findings: all three are fixed in the fixups (symlinked ancestors, parent traversal, entry metadata rebuilt from the spec).

I could not run the tests here: running commands needed approval this job does not have. The Notes below come from reading the code.

Notes, no action before merge

  • core/backup_manifest.py:209 and :113: a backend that is not a string ("backend": []) raises TypeError instead of ManifestError. Separately, "version": true or 1.0 passes the != VERSION check at :205, and duplicate file paths in a dir entry are accepted by both build and parse.
  • infrastructure/backup_scan.py:26 and :33: lstat and then path.open leave a window in which a link or a FIFO can be swapped in. The size from fstat and the digest can also describe different bytes on a live file. Opening with O_NOFOLLOW | O_NONBLOCK, checking fstat on the open handle and counting bytes while hashing would close both. Only a process running as the same user can do the swap, so I rate it a Note.
  • docs/audits/owned-resources-backup-2026-11-05.md §5: the list numbers the delivered manifest as item 1 under "Remaining slices", while the code and CHANGELOG call it slice 2 and the snapshot slice 3. The memory_config.py citations are about two lines off at HEAD.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 4s —— View job


Review 2 of 3: PR #960

There are no Blockers.

What the merge changed for this PR:

Notes, no action before merge

I could not run pytest here. The checks come from git diffs and from reading the code.

Verdict: approve
· branch fix/731-backup-manifest

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review 2 of 3: PR #960 (commit aef8895)

There are no Blockers. Review 1 (cbe7a8b) found none either, so nothing carried over.

What I reviewed: only aef8895 is new since cbe7a8b, and it merges main. I checked it with git:

I ran five reviews on the merge: code quality, security, performance, test coverage and documentation.

  • Callers: nothing merged from main imports or calls backup_manifest or backup_scan. No tool registers the handler.
  • Gates: the layer table and the architecture gates still accept the new modules.
  • Changelog: the entry matches the code.

Notes, no action before merge

I could not run pytest here. The checks above come from git diffs and from reading the code.

Verdict: approve

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants