Repository navigation
Conversation
…(GHSA-7848-qh48-mx2w) remember resolved its domain from caller-supplied directory or domain and never compared it with the session's project, so a caller steered by untrusted content could place text in another local project's domain, which that project's query_methodology then served as hot memory. A write may now target no domain, a shared domain that names no known repository, or the session's own project (CLAUDE_PROJECT_DIR, else the working directory). Another project's domain is refused with a ValidationError naming CORTEX_CROSS_PROJECT_WRITES, the local variable that may reopen it (a domain list or *); no tool argument can. The rule is one helper (handlers/_write_scope.py), applied to every writer that takes a caller domain: remember (and codebase_analyze through it), seed_project, ingest_prd, ingest_document. The reported PoC is reproduced on the real handlers and store: two git repositories under an isolated dev root, a session in beta, a directory inside alpha or an unregistered directory plus domain=alpha. On the unfixed handler both writes succeed (2 tests fail); with the fix they are refused, the session's own project and shared domains stay writable, and the variable reopens alpha.
Mutation gate on #940: 33 survivors. The tests checked neither the exact message and details, the trimming and case of the domain and of CORTEX_CROSS_PROJECT_WRITES, the working-directory fallback of the session, nor that seed_project, ingest_prd and ingest_document refuse under their own name (ingest_prd and ingest_document before any write).
…-domain # Conflicts: # CHANGELOG.md
…-domain # Conflicts: # CHANGELOG.md
…or an existing row (GHSA-7848-qh48-mx2w) Reviews 1-3 of #940, blocker: hot memories are 'domain = ? OR is_global', so remember(is_global=true) or anchor(is_global=true) still put text into every project's query_methodology context, and anchor rewrote any memory_id. Also swept, per the review: curation merges, supersedes_id targets and the memory-replica vpath refresh changed rows of any domain; seed_project checked the raw domain while remember resolves the directory first, so a refused run had already purged the seeded rows. - An explicit is_global=true needs CORTEX_CROSS_PROJECT_WRITES=*; a global scope the detector infers without it is kept local and reported. - anchor and supersedes_id refuse a memory of another project, or a global one without '*'; curation and the replica refresh skip such rows and create a new one instead. - seed_project checks the domain remember will write under. - The domain is compared by its canonical name (resolve_explicit_domain). New tests drive every path on the real handlers and store from a session in beta: 10 of 11 fail on the previous code. Tests that write a global memory on purpose now declare the allowance; the replica fake store matches the query that now also reads domain and is_global. 623 handler tests pass.
…hor writer line moved Check `unit` (run 42373733761, job 128407016638) failed on 4 tests. The PR makes an explicit is_global=true write require CORTEX_CROSS_PROJECT_WRITES=*, and two tests still wrote a global memory without it (test_global_memory_visible_across_domains, test_explicit_global_hooks_cross_projects). The anchor UPDATE moved from anchor.py:145 to :154, so the I2 writer allowlist pointed at a stale line. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsijeugoBcVdpBJric
…eft open Check `unit`, run 42377328684 (job 128417628663): the tests passed, then the mutation gate found 52 genuine survivors in _write_scope, anchor, remember and remember_helpers. Root causes: - The details of the global refusals, and the environment each check reads, were never asserted. - memory_writable called require_writable_memory with a tool name it then discarded, and anchor passed a reason string global_scope threw away: both only produced equivalent mutants. The refusal is now decided by a predicate (_refused_domain), and require_global_allowed raises the global refusal, called by global_scope and directly by anchor. - remember computes the scope twice (exact-duplicate record, then the write), so each refusal masked the other. The tests now drive the paths where only one decides: an explicit global replay of a local note, a detected or explicit global replay, and a superseding global write. - The curation loop's skip of an unwritable candidate was not distinguished from stopping the loop. tests_py/invariants/test_I2_canonical_writer.py: the anchor writer moved to line 157 (the import grew by three lines). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYqpV8QEumVvQXENjQN
… argument (GHSA-7848-qh48-mx2w)
The guard checked the domain remember writes under (the directory wins over the
argument) while the purge ran on the raw domain argument. A session in beta
calling seed_project({'domain': 'alpha'}) from its own directory passed the
check and deleted alpha's seeded memories. The purge now targets the checked
domain; a regression test fails without the fix.
Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF
…(GHSA-7848-qh48-mx2w) The checked domain is resolved in _parse_args and is the one the handler purges, writes and reports, as review 4 asked. The handler body goes back to what main has, so the mutation gate no longer takes its mutants as changed by this PR. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF
…ved domain (GHSA-7848-qh48-mx2w) Check `unit` (run 42742461370, job 129596153622) failed on three genuine mutation survivors: - remember._exact_record mutmut_14/15 drop the content or the tags from the global-scope detection of the pre-encode duplicate lookup. The locked insert repeats the lookup with the real scope, so a handler-level replay still reported exact_duplicate: the test now asserts that prepare_write itself returns the duplicate. - seed_project._parse_args mutmut_26 passes no domain to remember's resolver. Outside a known repository the explicit domain is then kept raw: the new test asserts it is stripped and lowercased as remember resolves it. Each mutant was applied by hand and fails the updated tests. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYqhUSqsRzpLYKtCFFQ
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @cdeust's task in 6m 6s —— View job Review of PR #969: done
I posted the full review as a separate comment: #969 (comment) Blocker B1. Notes
I couldn't run the tests or Verdict: block |
Review 1 of 3: PR #969 (GHSA-7848-qh48-mx2w)I ran five reviews: security, code quality, performance, test coverage and documentation. The
The one Blocker is below. BlockerB1.
Notes, no action before merge
Verdict: block |
…SA-7848-qh48-mx2w) Review 1 of #969, B1: ingest_prd and ingest_document checked the resolved domain and stored the raw argument. require_writable_domain now returns the domain to store and both handlers keep it. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]>
|
Claude finished @cdeust's task in 4m 17s —— View job Review 2 of 3: PR #969 (GHSA-7848-qh48-mx2w)
Review 1's B1 is fixed for the two ingest handlers. The same check/store mismatch still exists in BlockerB2.
Notes, no action before merge
Verdict: block |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7eb8a39611
ℹ️ 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".
|
|
||
| def _refused_domain(domain: str, environ: Mapping[str, str]) -> tuple[str, str] | None: | ||
| """(target, session) when `domain` is another project's than the session's.""" | ||
| target = resolve_explicit_domain(domain or "") |
There was a problem hiding this comment.
Distinguish canonical domains from explicit-name aliases
When a repository directory name collides with another repository family's canonical domain, this re-resolves an already-canonical identity into a different project. For example, repositories family-one and family-two have canonical domain family, while a directory named family with remote beta registers the alias family -> beta. A session in family-one then cannot remember anything in its own directory: _resolve_domain returns family, but this check changes it to beta and raises ValidationError. Conversely, a beta session passes the check for existing rows stored under family, allowing anchor or curation updates to the other family's memories. This is reproducible using the real _group_repos registry builder. Distinguish canonical domains obtained from paths or stored rows from raw explicit names, rather than applying alias resolution to both.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed. _refused_domain resolved its input through the name aliases even when the input was a domain identity already (from a path in remember and seed_project, from a stored row in anchor, supersedes_id, curation and replica refresh). Fixed in 68fb71a: require_writable_canonical compares a resolved domain as it is; only require_writable_domain (the raw name given to ingest_prd and ingest_document) resolves, once. Tests: the family writes in its own domain, the aliased project cannot change the family's rows, the alias given as a name still stores the session's project (tests_py/handlers/test_ghsa_7848_resolved_domain.py); they fail on the previous commit.
Generated by Claude Code
| if not memory_writable( | ||
| {"domain": rows[0].get("domain"), "is_global": rows[0].get("is_global")} | ||
| ): | ||
| return False, None |
There was a problem hiding this comment.
Search for a writable replica before falling back to insertion
When another project's replica already has the same vpath: tag, the first sync from the current project correctly falls back to creating a local row, but subsequent syncs can keep selecting the foreign row because the query applies LIMIT 1 before this permission check. The newly created writable replica is never considered. For example, repeated force=True syncs from beta after alpha has stored vpath:/memories/engineer/notes.md can insert a new beta row on every call instead of refreshing beta's existing row. Replica records explicitly bypass exact deduplication in memory_exact_sql.bypass, so even identical snapshots accumulate, leaving stale versions live and growing the store. Filter eligible replicas before limiting the query, or continue searching after an unwritable match.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed: LIMIT 1 ran before the permission check, so another project's replica hid this session's own and each sync inserted one more row. Fixed in 68fb71a: every replica of the block is read (ORDER BY id) and the first one this session may change is refreshed (test_another_projects_replica_does_not_hide_this_sessions_own, red on the previous commit).
Generated by Claude Code
…efresh finds this session's own row (GHSA-7848-qh48-mx2w) Codex on #969. Where a directory name is an alias of one project and the canonical domain of another repository family, re-resolving a canonical refused the family its own writes and let the aliased project change the family's rows. The replica lookup read one row (LIMIT 1): another project's replica hid this session's own and each sync inserted one more row. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]>
|
Claude finished @cdeust's task in 2m 42s —— View job Review 3 of 3: PR #969 (GHSA-7848-qh48-mx2w)
Review 1 B1 is still fixed. Review 2 B2 is fixed.
The Codex replica comment is fixed. BlockersNone. Notes, no action before merge
AcceptedThe whole PR is accepted (all 22 files, commits through Verdict: approve |
…n; the collision is tested on real repositories (GHSA-7848-qh48-mx2w) Review 2 of #969, B2. foo-lib-a and foo-lib-b form the family foo-lib while the repository zed lives in a directory named foo-lib, so the registry maps foo-lib to zed. remember(directory=foo-lib-a) from a zed session was checked as zed and stored as foo-lib. The tests build that layout with git repositories and the real registry and fail on 7eb8a39. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]>
|
Continued in #970: same commits (head a78f6e6), on a new branch so the last commit gets its own review. Generated by Claude Code |
…(GHSA-7848-qh48-mx2w) (#981) * fix(security): a memory write cannot target another project's domain (GHSA-7848-qh48-mx2w) remember resolved its domain from caller-supplied directory or domain and never compared it with the session's project, so a caller steered by untrusted content could place text in another local project's domain, which that project's query_methodology then served as hot memory. A write may now target no domain, a shared domain that names no known repository, or the session's own project (CLAUDE_PROJECT_DIR, else the working directory). Another project's domain is refused with a ValidationError naming CORTEX_CROSS_PROJECT_WRITES, the local variable that may reopen it (a domain list or *); no tool argument can. The rule is one helper (handlers/_write_scope.py), applied to every writer that takes a caller domain: remember (and codebase_analyze through it), seed_project, ingest_prd, ingest_document. The reported PoC is reproduced on the real handlers and store: two git repositories under an isolated dev root, a session in beta, a directory inside alpha or an unregistered directory plus domain=alpha. On the unfixed handler both writes succeed (2 tests fail); with the fix they are refused, the session's own project and shared domains stay writable, and the variable reopens alpha. * test: pin the GHSA-7848 refusal, its allowance and every writer's call Mutation gate on #940: 33 survivors. The tests checked neither the exact message and details, the trimming and case of the domain and of CORTEX_CROSS_PROJECT_WRITES, the working-directory fallback of the session, nor that seed_project, ingest_prd and ingest_document refuse under their own name (ingest_prd and ingest_document before any write). * test: a None domain next to a placeholder-named project, the exact no-project refusal * fix(security): no write reaches another project through global scope or an existing row (GHSA-7848-qh48-mx2w) Reviews 1-3 of #940, blocker: hot memories are 'domain = ? OR is_global', so remember(is_global=true) or anchor(is_global=true) still put text into every project's query_methodology context, and anchor rewrote any memory_id. Also swept, per the review: curation merges, supersedes_id targets and the memory-replica vpath refresh changed rows of any domain; seed_project checked the raw domain while remember resolves the directory first, so a refused run had already purged the seeded rows. - An explicit is_global=true needs CORTEX_CROSS_PROJECT_WRITES=*; a global scope the detector infers without it is kept local and reported. - anchor and supersedes_id refuse a memory of another project, or a global one without '*'; curation and the replica refresh skip such rows and create a new one instead. - seed_project checks the domain remember will write under. - The domain is compared by its canonical name (resolve_explicit_domain). New tests drive every path on the real handlers and store from a session in beta: 10 of 11 fail on the previous code. Tests that write a global memory on purpose now declare the allowance; the replica fake store matches the query that now also reads domain and is_global. 623 handler tests pass. * fix(tests): global writes in tests carry the GHSA-7848 allowance; anchor writer line moved Check `unit` (run 42373733761, job 128407016638) failed on 4 tests. The PR makes an explicit is_global=true write require CORTEX_CROSS_PROJECT_WRITES=*, and two tests still wrote a global memory without it (test_global_memory_visible_across_domains, test_explicit_global_hooks_cross_projects). The anchor UPDATE moved from anchor.py:145 to :154, so the I2 writer allowlist pointed at a stale line. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsijeugoBcVdpBJric * fix(security): pin every GHSA-7848 scope decision the mutation gate left open Check `unit`, run 42377328684 (job 128417628663): the tests passed, then the mutation gate found 52 genuine survivors in _write_scope, anchor, remember and remember_helpers. Root causes: - The details of the global refusals, and the environment each check reads, were never asserted. - memory_writable called require_writable_memory with a tool name it then discarded, and anchor passed a reason string global_scope threw away: both only produced equivalent mutants. The refusal is now decided by a predicate (_refused_domain), and require_global_allowed raises the global refusal, called by global_scope and directly by anchor. - remember computes the scope twice (exact-duplicate record, then the write), so each refusal masked the other. The tests now drive the paths where only one decides: an explicit global replay of a local note, a detected or explicit global replay, and a superseding global write. - The curation loop's skip of an unwritable candidate was not distinguished from stopping the loop. tests_py/invariants/test_I2_canonical_writer.py: the anchor writer moved to line 157 (the import grew by three lines). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYqpV8QEumVvQXENjQN * fix(security): seed_project purges the domain it checked, not the raw argument (GHSA-7848-qh48-mx2w) The guard checked the domain remember writes under (the directory wins over the argument) while the purge ran on the raw domain argument. A session in beta calling seed_project({'domain': 'alpha'}) from its own directory passed the check and deleted alpha's seeded memories. The purge now targets the checked domain; a regression test fails without the fix. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF * fix(security): seed_project resolves its domain once, in _parse_args (GHSA-7848-qh48-mx2w) The checked domain is resolved in _parse_args and is the one the handler purges, writes and reports, as review 4 asked. The handler body goes back to what main has, so the mutation gate no longer takes its mutants as changed by this PR. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF * fix(tests): pin the remember preflight scope and seed_project's resolved domain (GHSA-7848-qh48-mx2w) Check `unit` (run 42742461370, job 129596153622) failed on three genuine mutation survivors: - remember._exact_record mutmut_14/15 drop the content or the tags from the global-scope detection of the pre-encode duplicate lookup. The locked insert repeats the lookup with the real scope, so a handler-level replay still reported exact_duplicate: the test now asserts that prepare_write itself returns the duplicate. - seed_project._parse_args mutmut_26 passes no domain to remember's resolver. Outside a known repository the explicit domain is then kept raw: the new test asserts it is stripped and lowercased as remember resolves it. Each mutant was applied by hand and fails the updated tests. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYqhUSqsRzpLYKtCFFQ * fix(security): a write stores the domain its scope check resolved (GHSA-7848-qh48-mx2w) Review 1 of #969, B1: ingest_prd and ingest_document checked the resolved domain and stored the raw argument. require_writable_domain now returns the domain to store and both handlers keep it. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): a resolved domain is never resolved twice; a replica refresh finds this session's own row (GHSA-7848-qh48-mx2w) Codex on #969. Where a directory name is an alias of one project and the canonical domain of another repository family, re-resolving a canonical refused the family its own writes and let the aliased project change the family's rows. The replica lookup read one row (LIMIT 1): another project's replica hid this session's own and each sync inserted one more row. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): a name that is itself a project's domain is that domain; the collision is tested on real repositories (GHSA-7848-qh48-mx2w) Review 2 of #969, B2. foo-lib-a and foo-lib-b form the family foo-lib while the repository zed lives in a directory named foo-lib, so the registry maps foo-lib to zed. remember(directory=foo-lib-a) from a zed session was checked as zed and stored as foo-lib. The tests build that layout with git repositories and the real registry and fail on 7eb8a39. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): the directory a write stores reaches no other project (GHSA-7848) Review 1 of #970, blockers 1 to 3. The scope check read the domain only, while the SessionStart and auto-recall hooks select by directory_context: a memory is served to a session whose project root is that directory or lies below it. - remember refuses a directory inside another known project or above one, whatever the domain. - ingest_prd given text stores it under the session's project, no longer under the folder above the server's working directory; a file's folder passes the same rule, as does ingest_document's. - anchor, supersedes_id, curation merges and replica refreshes refuse a row whose directory belongs to another project, whatever its domain. CORTEX_CROSS_PROJECT_WRITES reopens it locally, as for a domain. Tests: test_ghsa_7848_directory_context.py, 9 of which fail without the change. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): compare a directory with the roots as written and as resolved; a replica refresh reads the row's directory (GHSA-7848) Codex on #970, two P1. - The discovery keeps the text of a symlinked parent of the projects (~/code -> /mnt/repos) and the hooks compare path text, while the check resolved the directory only: a directory naming the link reached no project. The directory and each root are now compared in both forms, and a directory inside a root is found by path, not only by resolve_cwd, which knows no root behind a symlinked parent. - try_block_replica_upsert selected id, domain and is_global, so a replica with a shared domain and another project's directory was refreshed. The row is read with its directory_context and checked whole. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): ingest_codebase and the session directory pass the directory rule (GHSA-7848) Review 2 of #970, blocker 2 and a note. Blockers 1 and 3 are the two Codex findings fixed in the previous commit. - ingest_codebase stores every row under the resolved project_path with a code:<name> domain, which no domain check reads: the path now passes require_writable_directory before anything is read or written. - ingest_prd checks the session's directory as well as a file's folder: a server started above the projects has no project of its own. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): the directory check is the hooks' own rule, over every root of a repository (GHSA-7848) Codex on #970 at 48c73b9, two P1 and one P2. The check and the hooks were two readings of the same path, and each difference was a way through. - The reach of a directory is now computed with shared.project_scope, the functions the hooks call: a backslash is a separator there, so /repos\\alpha is alpha's directory here too, and / reaches no session. - A repository's roots include those of its linked worktrees: a folder above alpha's worktree is served to a session opened in that worktree. - resolve_cwd finds a repository discovered through a symlinked parent, so a session opened there is its own project again and keeps its writes. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): the directory checks carry no default a mutant can swap (GHSA-7848) The mutation gate of #970 left twelve mutants alive. Six replaced the empty default of a directory lookup by another value that reaches no project either: the defaults are gone, the checks take the stored value as it is. The others changed the tool name in the refusal or dropped the caller's environment from the row check: the remember refusal is now matched from its first word, and a row is judged in the environment the caller passes, with the process environment refusing it. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): seed_project refuses before it purges; the directory rule reads the roots at every check (GHSA-7848) Review 1 of #977, B1: seed_project checked and purged the domain it resolved, then remember resolved that name once more (a family's domain can be another project's alias) and refused after the purge. A domain that does not stay itself is refused before anything is purged, and the directory is checked there too. Codex on #977: the roots of the known repositories were kept from the first check, so a worktree added while the server runs was unknown; they are read at every check. A directory is compared as written, with no character trimmed (a folder name may end with a space). A worktree entry is decoded as an OS path. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): the directory read has no default or slice a mutant can swap (GHSA-7848) The mutation gate of #977 left three mutants alive in _reached_projects: an empty default replaced by a text that reaches no project either, a slice one element longer of which only the first is read, and None in place of False for is_global. The directory goes to project_ancestors as given (it accepts None), its first element is read by index, and the is_global value is a named constant. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsWnKSjpX4oNV19PCF Signed-off-by: Claude <[email protected]> * fix(security): the mutation gate of _reached_projects has no survivor left (GHSA-7848) Check `unit` of run 42885571909 (job 130073499102) failed on two GENUINE survivors in `_write_scope._reached_projects`: - mutmut_7 reads `ancestors[1]` for the absolute test. A relative directory of two parts ("src/notes") has one ancestor only, so the mutant raises IndexError where the rule returns no project. No test passed such a path: test_a_relative_directory_or_a_label_reaches_no_session now does. - mutmut_15 passes None instead of `_LOCAL` (False) as is_global. memory_matches_project reads it only as `if is_global:`, so both values take the same branch for every input: equivalent by construction, registered in memory/mutation-equivalents.json with its exact line pair. Local: the six test_ghsa_7848_* files and tests_py/scripts/test_mutation_equivalents* pass (107), the mutant-7 source fails the new assertion with IndexError, and scripts/mutation_check.sh on _reached_projects exits 0. ruff and the craftsmanship gate pass. <!-- ci-autopilot attempt=1 signature=merge,unit:surviving-mutants-_write_scope._reached_projects --> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYqj3CUJAQ7tAriaQHy * fix(security): a prospective trigger fires only in its own project (GHSA-7848-qh48-mx2w) Review 1 of #981: remember turns a phrase such as TODO: into a keyword_match trigger, and a trigger fired in every project whose first message or query shared a keyword, so caller text still reached other projects. - core.prospective.trigger_in_scope: a trigger fires only in a session whose project root is its target_directory or lies below it, the hooks' own rule; a trigger without a directory reaches no session. - query_methodology and recall apply it before check_trigger. - create_trigger stores the session's project when no directory is given and passes a given one through require_writable_directory. - ingest_document.py is back at the 300-line cap (305 before). Proof: tests_py/handlers/test_ghsa_7848_triggers.py on two git repositories; the two attack tests fail without the change to the two readers. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYrKg46KKYpvLPXcW4M Signed-off-by: Claude <[email protected]> * fix(security): the trigger changes leave no surviving mutant (GHSA-7848) The `unit` check of Pipeline run 42931331400 (job 130231355842) failed in its mutation step: five mutants survived in the functions f14286f changed. - query_methodology._get_fired_triggers mutmut_13 (`continue` -> `break`): no test stored an out-of-scope trigger before an in-scope one. The new test stores an alpha trigger first and still sees beta's fire in beta. - create_trigger.handler mutmut_63/66/67 (the tool name passed to require_writable_directory): the refusal test now pins the `create_trigger:` prefix of the message. - prospective.trigger_in_scope mutmut_9 (`False` -> `None` for is_global): equivalent by construction, registered in memory/mutation-equivalents.json with the same rationale as _write_scope._reached_projects mutmut_15. Local receipt (SQLite backend, MUTMUT_MAX_CHILDREN=1 as in CI): ci_mutation_diff plan/run_job on the three files, all exit 0. ci-autopilot attempt=1 signature=merge,unit|unit: surviving mutants in the changed functions of mcp_server/core/prospective.py Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYucf61S8fDCRuJ7xdj --------- Signed-off-by: Claude <[email protected]> Co-authored-by: Claude <[email protected]>
Fix for GHSA-7848-qh48-mx2w (accepted, CVE requested). The advisory is published with 4.24.0. Developed and verified in the advisory's private fork; it is merged here because a private fork cannot run the required checks.
Rule
A memory write may target:
CLAUDE_PROJECT_DIR, else the working directory).Another project's domain is refused with a
ValidationErrornamingCORTEX_CROSS_PROJECT_WRITES. That local variable, a domain list or*, can reopen such writes; no tool argument can. Owner decision 2026-11-07.One helper,
mcp_server/handlers/_write_scope.py, applies the rule to:remember, andcodebase_analyzethrough it;seed_project;ingest_prd;ingest_document.Proof
tests_py/handlers/test_ghsa_7848_cross_project_write.pyreproduces the report on the real handlers and store, with two git repositories and a session in one of them:*reopens every project.On the unfixed handler, the two attack tests fail, because the marker reaches the other project's
query_methodologycontext.Local full suite on PostgreSQL: 11,992 passed, 0 failed. The 41 errors were
uv venvpicking an x86_64 Python that this host cannot run.ruff: clean;check_craftsmanship.py: OK.Generated by Claude Code