Skip to content

fix(security): a memory write cannot target another project's domain (GHSA-7848-qh48-mx2w) - #940

Closed
cdeust wants to merge 12 commits into
mainfrom
fix/ghsa-7848-session-domain
Closed

cdeust wants to merge 12 commits into
mainfrom
fix/ghsa-7848-session-domain

Conversation

@cdeust

@cdeust cdeust commented Nov 7, 2026

Copy link
Copy Markdown
Owner

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:

  • no domain;
  • a shared domain that names no known repository;
  • 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. 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, and codebase_analyze through it;
  • seed_project;
  • ingest_prd;
  • ingest_document.

Proof

tests_py/handlers/test_ghsa_7848_cross_project_write.py reproduces the report on the real handlers and store, with two git repositories and a session in one of them:

  • a directory inside the other project is refused;
  • an unregistered directory plus the other project's domain is refused;
  • the session's own project and shared domains stay writable;
  • the variable reopens the other project, and * reopens every project.

On the unfixed handler, the two attack tests fail, because the marker reaches the other project's query_methodology context.

Local full suite on PostgreSQL: 11,992 passed, 0 failed. The 41 errors were uv venv picking an x86_64 Python that this host cannot run. ruff: clean; check_craftsmanship.py: OK.

…(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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 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-10T06:31:56.866251Z 3b7f417 New commits
🔒 Security Review ✅ Completed 2026-11-10T06:39:55.406561Z 3b7f417 New commits

Security findings

Advisory findings (3)

ℹ️ 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.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 5m 24s —— View job


Review 1 of 3: PR #940. Verdict: block

  • Read the diff
  • Run five reviews: security, code quality, tests, docs, performance
  • Check the findings myself
  • Post the summary: full review

The domain guard itself is sound, but the attack still works without naming a domain. That is one Blocker:

  • remember(is_global=True) bypasses the guard. The guard returns early when no domain is given, so the write is stored as a global memory. query_methodology then serves that text in every project's hot memories, which is the injection this advisory is meant to close.
  • anchor has the same hole. It can rewrite any memory's content and set it global, with no domain check.

The full review also has 3 notes, which need no action before merge.

I could not run pytest, ruff or the craftsmanship gate here, because Python commands need approval in this job. The findings come from tracing the 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: 265ff1cf91

ℹ️ 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 thread mcp_server/handlers/seed_project.py Outdated
root = Path(directory).expanduser().resolve()
# source: ADR-0445
domain = args.get("domain", "") or root.name
require_writable_domain(domain, "seed_project") # GHSA-7848-qh48-mx2w

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 the resolved seed target before deleting memories

When a session in beta calls seed_project with directory pointing to known repository alpha and domain="beta", this check permits the request, and the handler then deletes all existing beta memories tagged seeded. However, _store_discoveries passes both arguments to remember, whose _resolve_domain prioritizes the directory and resolves the target to alpha; the newly added guard there raises ValidationError. The rejected request therefore leaves the session's existing seed memories deleted. Validate the effective domain using the same resolution as remember before performing the purge, while retaining authorization for the purge domain itself.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Nov 7, 2026

Copy link
Copy Markdown

Review 1 of 3: PR #940 (GHSA-7848-qh48-mx2w)

I ran five reviews (security, code quality, tests, docs, performance) and checked the main finding against the code myself. One Blocker.

The domain guard itself is sound:

  • It sits in remember, so codebase_analyze, import_sessions, wiki_* and the other writers that go through remember are covered too.
  • Layering is respected.
  • The repo registry is cached, so the extra cost per call is negligible.

The Blocker is that the same attack still works without naming a domain.

Blocker: a write can still reach another project's query_methodology context through global scope

The advisory's attack ends with text landing in another project's hot memories. The guard checks only domain, but hot-memory reads also return every global row. Traced:

  • remember accepts a caller-set is_global argument (mcp_server/handlers/remember_schema.py:303). An explicit value is always honoured, because resolve_global_scope returns True whenever explicit is set (mcp_server/core/global_detector.py:273). The row is then stored as global (remember.py:190-202).
  • require_writable_domain("") returns at once (_write_scope.py:49), because an empty domain is allowed.
  • query_methodology._get_hot_memories("alpha", …) calls get_memories_for_domain, which selects WHERE (domain = ? OR is_global = …) (sqlite_store_queries.py:46, pg_store_queries.py:34).

Scenario: in the test's setup, a session in beta calls remember({"content": MARKER, "is_global": True, "force": True}). The marker then shows up in _alpha_context(). That is the outcome test_a_directory_inside_another_project_is_refused asserts cannot happen.

anchor (mcp_server/handlers/anchor.py:121-150) has the same hole:

  • It takes any memory_id, puts the caller's reason at the front of that memory's content, sets its heat to 1.0, and sets is_global from the arguments. It never checks the domain.
  • So a session can rewrite another project's memory directly, or make its own memory global.

Fix:

  • An explicit is_global=True, and anchor with is_global=True, should require CORTEX_CROSS_PROJECT_WRITES=*.
  • anchor should refuse a memory_id whose domain fails require_writable_domain.
  • Add a test for each to test_ghsa_7848_cross_project_write.py.

The security reviewer also traced, but I have not checked myself, three more paths that modify existing rows without checking their domain:

  • merge candidates in try_curation, which searches with no domain filter (remember_helpers.py:450);
  • supersedes_id targets (remember_helpers.py:845);
  • the replica vpath: upsert (remember_helpers.py:373).

These are worth sweeping in the same fix.

Fix this →

Notes, no action before merge

  • seed_project, ingest_prd and ingest_document check the raw domain without mapping it to its canonical name first, unlike remember.py:84. An alias passes the check. That is harmless today only because the read side matches domains exactly.
  • project_domains() only knows repos under the dev roots, and it is cached for the life of the process. A repo outside those roots, or one cloned after the server starts, is unprotected.
  • The guards in seed_project, ingest_prd and ingest_document have no tests. record_session_end_memory.py:95 catches the new ValidationError and logs it only at debug level.

I could not run pytest, ruff or the craftsmanship gate: running Python in this job needs approval it was not given. The findings above come from tracing the code.

Verdict: block

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).
@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

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


Review 2 of 3: PR #940. Verdict: block

  • Check whether review 1's Blocker is fixed
  • Run five reviews (security, code quality, tests, docs, performance) on the two commits pushed since 265ff1c
  • Post the summary: full review

Review 1's Blocker is still open. The two commits since then (8614c26, edde263) only change tests, and nothing in remember.py, anchor.py or _write_scope.py has changed:

  • remember(is_global=True) still gets past the guard. The guard returns early when no domain is given, and the row is stored as global. Another project's query_methodology then serves it in its hot memories.
  • anchor has the same hole. It can still rewrite any memory and make it global without a domain check.

The new tests have no Blockers. I traced them against the code and they should pass in CI. The full review has 3 notes, which need no action before merge.

I didn't run pytest, ruff or the craftsmanship gate. The findings come from tracing the code.

@claude

claude Bot commented Nov 7, 2026

Copy link
Copy Markdown

Review 2 of 3: PR #940 (GHSA-7848-qh48-mx2w)

The last review covered 265ff1c. Since then the PR's own commits are 8614c26 and edde263, which change only tests_py/handlers/test_ghsa_7848_cross_project_write.py. fe6dd31 merges main and changes nothing in this fix. I ran five reviews on those two commits (security, code quality, tests, docs, performance), then checked the earlier Blocker against HEAD myself.

Blocker (from review 1, still open): global scope bypasses the guard

No commit since 265ff1c touches remember.py, anchor.py or _write_scope.py. At HEAD:

  • remember.py:178 checks only the domain. require_writable_domain("") returns at _write_scope.py:49.
  • remember.py:190-202 then stores is_global=True whenever the caller passes it, because resolve_global_scope returns True for an explicit flag (global_detector.py:273).
  • Hot-memory reads in another project return every global row: WHERE (domain = ? OR is_global = 1) (sqlite_store_queries.py:46, pg_store_queries.py:34).

Scenario: a session in beta calls remember({"content": MARKER, "is_global": True, "force": True}). The marker then reaches alpha's query_methodology context.

anchor.py:121-149 is still open as well. For any memory_id and any domain, it puts the caller's reason at the front of the memory's content and sets is_global from the arguments. It never calls the guard.

Fix this →

The new tests have no Blockers. I traced their expected strings, the write_page monkeypatch target, the fixture's cache clearing and the asyncio markers against the code, and they should pass in CI. The file is 229 lines, under the craftsmanship limit.

Notes, no action before merge

  • test_ingest_document_refuses_before_writing (:220) never checks that nothing was written, unlike the ingest_prd test, which asserts written == []. To match it, stub _persist.
  • The name test_without_the_variable_no_project_is_allowed (:165) overstates the behaviour, since the session's own project stays writable. ..._no_other_project_is_allowed would be accurate.
  • The Codex P2 is still open: seed_project with directory=<alpha> and domain="beta" passes _parse_args, purges beta's seeded memories, and then remember refuses. Validate the domain remember would resolve before the purge.

I didn't run pytest, ruff or the craftsmanship gate here. Everything above comes from reading and tracing the code.

Verdict: block

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 1m 5s —— View job


Review 3 of 3: PR #940 (GHSA-7848-qh48-mx2w)

Review 2 covered edde263. The only commit since then is 9457b60, which merges main (#941, GHSA-5w3v-7v7h-q9x4). git diff origin/main...HEAD still shows the same 8 files and +307/-0, and the merge touches none of them. There was no new PR code to review, so I did not run the five reviewer agents again.

Blocker (from reviews 1 and 2, still open): global scope bypasses the guard

remember.py, anchor.py, _write_scope.py and seed_project.py are identical to edde263. At HEAD:

  • remember.py:178 checks only domain, and require_writable_domain("") returns early.
  • remember.py:190-191 then honours a caller's is_global=True (explicit=bool(args.get("is_global", False))).
  • In another project, hot-memory reads return every global row: WHERE (domain = ? OR is_global = …).

So a session in beta that calls remember({"content": MARKER, "is_global": True, "force": True}) still gets the marker into alpha's query_methodology context.

anchor.py:140-148 still rewrites any memory_id's content and sets is_global from the arguments. It never calls the guard.

Fix this →

Accepted vs open

  • Accepted: the domain guard as written. That is _write_scope.py, domain_mapping.py, the calls in remember.py, seed_project.py, ingest_prd.py and ingest_document.py, the tests, and the CHANGELOG (commits b42058e, 8614c26, edde263). It correctly refuses a write that names another project's domain, and it can merge as is.
  • Open, for a refinement PR: the bypass through global scope in mcp_server/handlers/remember.py (explicit is_global, lines 190-191) and mcp_server/handlers/anchor.py (lines 140-148). No commit in this PR fixes either one. Until a fix lands, the advisory's attack still works by that route. If this part merges first, the CHANGELOG entry and the advisory should not claim GHSA-7848 is fully closed.

Notes, no action before merge

  • seed_project.py:121 (Codex P2): with directory=<alpha>, domain="beta", beta's seeded memories are purged before remember refuses the write.
  • seed_project, ingest_prd and ingest_document check the raw domain without mapping it to its canonical name first, unlike remember.py:84.
  • test_ingest_document_refuses_before_writing never checks that nothing was written.

I couldn't run pytest, ruff or the craftsmanship gate, and I couldn't post a separate comment with gh pr comment, because this job needs approval for those commands and for writing a temp file. This comment is the review. The findings come from tracing the code at 9457b60.

Verdict: block

…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.
@cdeust cdeust added the claude-rereview ADR-1119: allows one more Claude review (a fourth) on this PR label Nov 7, 2026

@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: d9f160ea76

ℹ️ 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 thread mcp_server/handlers/seed_project.py Outdated
domain = args.get("domain", "") or root.name
# GHSA-7848-qh48-mx2w: check the domain remember will write each file under
# (the directory wins over the argument) before anything is purged.
require_writable_domain(_remember_domain(str(root), domain), "seed_project")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Authorize the purge domain independently of the write domain

When a session in beta calls seed_project with directory pointing to beta and domain="alpha", _remember_domain resolves to beta, so this check passes without any cross-project allowance. However, handler still passes the original domain to delete_memories_by_tag("seeded", domain=domain), deleting alpha's seeded memories before storing the discoveries in beta. The effective-domain check fixes the previously reported rejected-write scenario, but the unchanged purge argument provides a separate cross-project deletion path. Validate the purge domain as well as the resolved write domain before allowing either mutation.

Useful? React with 👍 / 👎.

Comment on lines +403 to +406
if not memory_writable(
{"domain": rows[0].get("domain"), "is_global": rows[0].get("is_global")}
):
return False, None

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 Search for a writable replica before falling back to insertion

When the same vpath: is replicated from two projects, the unscoped LIMIT 1 query can keep returning the first project's now-unwritable row, even after the fallback has created a writable replica in the current project. Every subsequent refresh that selects that row returns here instead of updating the current project's replica. With forced writes, this creates another row on every sync, retaining stale snapshots and growing the store; exact-replay deduplication does not prevent this because memory_exact_sql.bypass explicitly excludes memory-replica writes. Search for an eligible existing replica rather than abandoning the upsert after checking only one arbitrary match.

Useful? React with 👍 / 👎.

…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

cdeust commented Nov 7, 2026

Copy link
Copy Markdown
Owner Author

Diagnostic du check unit (run 42373733761, job 128407016638, 4 tests en échec) : la PR exige désormais CORTEX_CROSS_PROJECT_WRITES=* pour toute écriture is_global=true. Deux tests écrivaient encore une mémoire globale sans cette autorisation (test_global_memory_visible_across_domains, test_explicit_global_hooks_cross_projects[auto_recall|session_start]). Le troisième échec vient de l'UPDATE de anchor.py, passé de la ligne 145 à 154 : la liste ALLOWED_WRITERS de l'invariant I2 pointait sur l'ancienne ligne. merge est l'agrégat et échoue parce que unit échoue.

Commit poussé : 10b5afb (les tests posent l'autorisation via monkeypatch, comme test_remember/test_anchor dans cette PR ; I2 passe à anchor.py:154). Aucun code de production modifié.

Vérifié en local : les 4 tests passent ; tests_py/invariants, tests_py/hooks et tests_py/handlers donnent 2983 passés et 2 échecs (test_entry_auto_dispatch[False-*]), qui échouent aussi sur origin/main dans cet environnement et passent en CI.

Non vérifié : la CI de 10b5afb (en cours). La branche a aussi 1 commit de retard sur main : le rebase avec --force-with-lease a été refusé par le contrôle de permissions de cette session, il n'a donc pas été poussé. Le conflit CHANGELOG qu'il soulève est mécanique : garder l'entrée GHSA-7848 longue et supprimer la courte.


Generated by Claude Code

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 3m 19s —— View job


Review 4 of 3: PR #940 (GHSA-7848-qh48-mx2w)

  • List commits since 9457b60: d9f160e (the fix) and 10b5afb (tests only)
  • Check the earlier Blocker (global scope, anchor): fixed
  • Run five reviews (security, code quality, tests, docs, performance) on the new commits
  • Post the verdict

Earlier Blocker: fixed

  • remember: an explicit is_global=true is now refused without CORTEX_CROSS_PROJECT_WRITES=*, through global_scope in _exact_record and _handler_impl (remember.py:196, :341). A global scope that the detector infers on its own is kept local and reported in global_reason.
  • anchor: it now calls require_writable_memory on the target row and refuses an explicit is_global (anchor.py:122-126, :145).
  • Sweep of existing rows: supersedes_id, curation merges and replica refreshes now skip or refuse rows the session may not change (remember.py:231, remember_helpers.py:403, :469).

Blocker: seed_project checks one domain and deletes from another

d9f160e changed the guard so it checks the domain remember would write under. That domain comes from the directory first. The purge still uses the raw domain argument:

  • seed_project.py:121 sets domain = args.get("domain") or root.name.
  • seed_project.py:124 checks _remember_domain(str(root), domain). For a directory inside a known repo, that is the repo's domain (remember.py:84-87).
  • seed_project.py:199 runs delete_memories_by_tag("seeded", domain=domain) on the raw argument.

Scenario: a session in beta calls seed_project({"domain": "alpha"}). The directory defaults to the working directory, which is beta. The check sees beta and passes. Every seeded memory in alpha is then deleted, and the new discoveries are written under beta. Before this commit, the check ran on the argument itself, so this call was refused. This commit reopened the path. The Codex P1 on seed_project.py:124 reports the same thing.

The CHANGELOG line "seed_project checks the domain remember will use before it purges anything" (CHANGELOG.md:21-22) is true of the check but not of the purge.

Fix: resolve the domain once in _parse_args, then use that same value for the check, the purge, the writes and the response. Add a test: call seed_project with directory=<beta>, domain="alpha" and dry_run=False, and assert that alpha's seeded row survives.

Fix this →

Accepted vs open

  • Accepted: everything else in the PR. That is the domain guard from the earlier commits, plus the global-scope, anchor, supersedes_id, curation and replica guards in d9f160e (_write_scope.py, anchor.py, remember.py, remember_helpers.py), and the tests in 10b5afb.
  • Open: mcp_server/handlers/seed_project.py from d9f160e (lines 121-124 against line 199), and the matching CHANGELOG sentence. If this goes to a refinement PR, the simplest interim fix is to go back to checking the raw domain as well as the resolved one.

Notes, no action before merge

  • remember_helpers.py:387-406 (Codex P2): the LIMIT 1 replica lookup can keep returning a row the session may not change. Each forced sync then adds a new replica row. Filter by writability in the SQL, or pick the first writable match.
  • write_post_store.py:129-144: synaptic tagging still raises the heat and importance of hot memories from any project. This is unchanged by this PR and injects no content.
  • The is_global descriptions (remember_schema.py:306, anchor.py:70) don't say that CORTEX_CROSS_PROJECT_WRITES=* is now required.

I didn't run pytest, ruff or the craftsmanship gate. I also couldn't post a separate comment with gh pr comment, because this job blocked writing the comment body to a temp file. This comment is the review. The findings come from tracing the code at 10b5afb.

Verdict: block

@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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 10b5afbb49

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread mcp_server/handlers/_write_scope.py Outdated
Comment on lines +52 to +54
target = resolve_explicit_domain(domain or "")
if not target or target not in project_domains():
return

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 Security Review · Automatically triggered

P2 Badge Security: Reject writes to unregistered repository domains

When the victim repository is outside CORTEX_DEV_ROOT or the four hard-coded roots/depths, a lower-privileged local MCP session can pass its domain and content to remember. project_domains() omits that real repository, so this early return treats its domain as shared and skips the session-domain check; the row is inserted under the victim domain and mandatory query_methodology later returns it as hot memory. A targeted probe reproduced beta → unregistered-victim as ALLOWED. Distinguish intentionally shared domains from unresolved project domains, or deny unresolved explicit targets.

SECURITY.md reference: SECURITY.md:L15-L16

Useful? React with 👍 / 👎.

…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

cdeust commented Nov 9, 2026

Copy link
Copy Markdown
Owner Author

CI autopilot : diagnostic du check unit (run 42377328684, job 128417628663, runner Hosted Compute Agent 20261028.624)

Les 12328 tests passent. C'est le gate de mutation qui échoue : mutation: 8 file(s), 4 failed, avec 52 survivants authentiques (28 dans _write_scope, 4 dans anchor, 19 dans remember, 1 dans remember_helpers). merge échoue parce qu'il dépend de unit.

La cause racine tient en trois points :

  • Les details des refus globaux et l'environnement passé à chaque contrôle n'étaient jamais vérifiés.
  • memory_writable et anchor passaient des valeurs que l'appelé jetait, ce qui ne produisait que des mutants équivalents.
  • remember calcule la portée deux fois (l'enregistrement de doublon exact, puis l'écriture), si bien que chaque refus masquait l'autre.

Commit poussé : a0cd381

  • _refused_domain (un prédicat) et require_global_allowed remplacent les appels dont le résultat était jeté. Le comportement reste identique, et tous les tests existants passent.
  • Nouveau fichier tests_py/handlers/test_ghsa_7848_scope_decisions.py (14 tests). Il tue chaque mutant listé :
    • les refus exacts et leurs details ;
    • l'environnement lu ;
    • la relecture globale explicite d'une note locale (refusée, pas traitée comme doublon) ;
    • les doublons globaux détectés par le contenu, par les tags, ou explicites ;
    • l'écriture supersedes_id globale ;
    • le résultat non stocké ;
    • la curation qui saute le candidat d'alpha et fusionne celui de beta.
  • Dans test_I2_canonical_writer, l'écrivain d'anchor est passé en ligne 157.

Vérifié localement :

  • mutmut 3.8.1, avec la sélection de tests de ci_mutation_diff.select_tests : 0 survivant sur _write_scope. Sur anchor, aucun survivant dans _require_anchor_allowed ; le seul survivant (_build_anchor_tags) est sur une ligne non touchée. Sur les mutants visés de remember et remember_helpers, aucun survivant.
  • tests_py/handlers, tests_py/invariants et test_team_project_scope : 2077 réussis, 97 sautés.
  • ruff sur les fichiers modifiés, et check_craftsmanship : OK.

Non vérifié :

  • Le gate complet en CI (Python 3.12, PostgreSQL) : torch ne s'installe pas dans mon environnement, et le CI comptait des survivants que mon run local tuait déjà. Le prochain unit fait foi.
  • La branche a 3 commits de retard sur main. J'ai fait le rebase localement : un seul conflit, mécanique, dans la section Security du CHANGELOG, où les deux entrées ont été gardées. Mais le force-push avec lease a été refusé par les permissions de cette session, donc il n'est pas poussé.

Generated by Claude Code

… 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

cdeust commented Nov 9, 2026

Copy link
Copy Markdown
Owner Author

Blocage du review 4 corrigé dans a653394 : la purge de seed_project cible maintenant le domaine vérifié par _parse_args (celui sous lequel remember écrit, le répertoire prime), plus l'argument brut. Test de régression ajouté : une session dans beta qui appelle seed_project({"domain": "alpha"}) depuis son répertoire ne purge que beta ; il échoue sans le correctif. Phrase du CHANGELOG alignée. Les trois notes non bloquantes (recherche de réplique LIMIT 1, marquage synaptique, descriptions de is_global) restent pour une PR de suivi.

🤖 Generated with Claude Code


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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: a6533948f0

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

source=str(path), version=content_version(raw), source_kind=kind
)
domain = (str(args.get("domain") or "").strip()) or "documents"
require_writable_domain(domain, "ingest_document") # GHSA-7848-qh48-mx2w

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 Security Review · Automatically triggered

P2 Badge Security: Scope document receipt reconciliation to the authorized domain

Requires a beta MCP session that can name and read a file previously ingested into alpha. This guard authorizes only domain="beta", but _retire_obsolete looks up receipts by source/version and writer without domain and stales unmatched rows. Reingesting the same file from beta with a different title therefore marks alpha’s protected summary stale; a targeted repro changed alpha row 1 from is_stale=false to true while the call passed this guard. Scope receipt lookup, reuse, and retirement to the authorized domain, or authorize each existing row before mutation.

SECURITY.md reference: SECURITY.md:L15-L16

Useful? React with 👍 / 👎.

…(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

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

CI autopilot : diagnostic du check unit (run 42742461370, job 129596153622, image ubuntu-26.04)

Les tests passent ; c'est le gate de mutation qui échoue (mutation: 8 file(s), 2 failed), avec 3 survivants authentiques. merge échoue parce qu'il dépend de unit. Cause racine :

  • remember._exact_record mutmut_14/15 retirent le contenu ou les tags de la détection globale du pré-contrôle de doublon. L'insertion sous verrou refait la recherche avec la vraie portée, donc un rejeu via le handler renvoyait quand même exact_duplicate : le test ne voyait pas le pré-contrôle.
  • seed_project._parse_args mutmut_26 ne passe plus le domaine au résolveur de remember. Hors dépôt connu, le domaine explicite restait alors brut (non normalisé), et aucun test ne le vérifiait.

Commit poussé : 3b7f417 (tests seulement)

  • test_a_detected_global_memory_is_its_own_exact_duplicate vérifie désormais que remember.prepare_write renvoie lui-même le doublon, avant toute préparation.
  • Nouveau test_seed_project_reports_the_domain_remember_resolves : " Shared-Notes " depuis un répertoire non enregistré donne shared-notes.

Vérifié localement :

  • Chacun des trois mutants a été appliqué à la main, et les tests mis à jour échouent : 2 échecs pour m14, 1 pour m15, 1 pour m26.
  • Les trois fichiers test_ghsa_7848_* passent sans mutant : 45 tests OK.
  • ruff (check et format) et check_craftsmanship sont OK.

Non vérifié :

  • Le gate complet en CI (PostgreSQL, torch absent ici). Le prochain unit fait foi.
  • La branche a 15 commits de retard sur main. Le rebase n'est pas poussé : dans cette session, le force-push avec lease est refusé par le classifieur de permissions.

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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 3b7f417c2c

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.


def _prepare_request(args: dict, store: MemoryStore, write_class: str) -> GateRequest:
domain = _resolve_domain(args.get("directory", ""), args.get("domain", ""))
require_writable_domain(domain, "remember") # GHSA-7848-qh48-mx2w

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 Security Review · Automatically triggered

P2 Badge Security: Authorize directory context before storing memories

Requires a local beta MCP session and a victim project beneath a known ancestor. This is distinct from the unresolved-domain issue: both repositories can be registered and the checked domain is beta. The new guard accepts remember({domain: "beta", directory: "/home/alice"}), but the raw directory is stored as directory_context; alpha's session-start and auto-recall paths treat any ancestor of alpha as in scope, so the attacker's hot text is injected into alpha. Bind or canonicalize directory_context to the authorized session project, including in direct ingesters. Cortex's user-level filesystem/store access is documented here.

SECURITY.md reference: SECURITY.md:L15-L16

Useful? React with 👍 / 👎.

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Continued in #969 on a new branch, same commits (head 3b7f417). This PR used its review budget and its head was never reviewed: the last verdict (block, 2026-11-07) is on an earlier head. A new branch gives the current head its review (ADR-1119).


Generated by Claude Code

@cdeust cdeust closed this Nov 10, 2026
github-actions Bot pushed a commit that referenced this pull request Nov 11, 2026
…(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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-rereview ADR-1119: allows one more Claude review (a fourth) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants