Skip to content

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

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

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

Conversation

@cdeust

@cdeust cdeust commented Nov 10, 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.

Continuation

Continues #969 with the same commits (head a78f6e67). Review 3 of #969 approved commits through 68fb71a; the last commit a78f6e67 (alias collision in require_writable_domain, with tests on real git repos) had no review of its own because the review budget of that branch was used. This branch gives the head its own review.

Continuation

Continues #970, whose three reviews are spent and whose head (48c73b9) fails the mutation gate. Same commits, plus two:

  • 6e958ecc: the directory check uses the hooks' own functions (project_ancestors, memory_matches_project) over every root of a repository, linked worktrees included; a backslash path and a symlinked root or session are covered by tests (Codex findings on 48c73b9).
  • ff7b4af5: the twelve mutants the gate of fix(security): a memory write cannot target another project's domain (GHSA-7848-qh48-mx2w) #970 left alive. Six swapped the empty default of a directory lookup for another value that reaches no project either: the defaults are removed. The others changed the tool name of the refusal or dropped the caller's environment from the row check: both are now asserted.

Local check on _write_scope.py (every function) and remember._prepare_request with the five test_ghsa_7848_* files: no survivor in _write_scope.py; the survivors left in _prepare_request are on lines this pull request does not change, and the gate's full selection killed them on #970.


Generated by Claude Code

cdeust and others added 20 commits November 7, 2026 09:32
…(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).
…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
…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]>
…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]>
…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]>
…(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]>
…esolved; 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]>
…ectory 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]>
… 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]>
…p (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]>
@chatgpt-codex-connector

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

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T23:32:28.602299Z 77e89b3 New commits
🔒 Security Review ✅ Completed 2026-11-10T23:38:41.037069Z 77e89b3 New commits

Security findings

Advisory findings (2)

ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff7b4af5bf

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +125 to +126
domain = _remember_domain(str(root), domain) or domain
require_writable_canonical(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.

P2 Badge Validate the seed directory before purging existing memories

When a session in beta calls seed_project with the parent directory containing both alpha and beta and domain="beta", this domain-only check succeeds. The handler then deletes all existing seeded memories in beta, but the first discovery passed to remember raises the new directory-scope ValidationError because that parent directory also reaches alpha. This leaves the existing bootstrap memories deleted without replacements. Check require_writable_directory(str(root), "seed_project") before the destructive purge, alongside the domain validation.

Useful? React with 👍 / 👎.

Comment thread mcp_server/shared/domain_mapping.py Outdated
Comment on lines +525 to +526
if registry.root_forms is None:
registry.root_forms = {

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 Refresh linked-worktree roots before authorizing writes

If another project's linked worktree is created after the first directory check, this cached root map never includes it for the lifetime of the server. A session in beta can then call remember with a shared domain and directory set to the parent of the new external alpha worktree: _prepare_request accepts the write, while memory_matches_project serves that memory to sessions in the alpha worktree. I reproduced this with real repositories; clearing the registry makes the same request correctly raise ValidationError. Refresh or invalidate the worktree-root inventory before using it to authorize writes so creating a worktree during a running session does not reopen the cross-project injection path.

Useful? React with 👍 / 👎.

Comment thread mcp_server/handlers/_write_scope.py Outdated
"""
# The text the hooks compare: separators normalised, no trailing one. A
# relative directory, a label such as `run:<id>` or `/` is served to no session.
read = project_ancestors((directory or "").strip())[:1]

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 Preserve significant whitespace in directory scope checks

If a known repository's directory name ends in whitespace, .strip() changes its filesystem identity before the authorization check, although the stored directory_context and the hooks' project_ancestors preserve that whitespace. For example, with repositories /dev/alpha and /dev/beta, a session in beta can ingest /dev/alpha /export.html using the default documents domain: _reached_projects returns an empty set, ingestion succeeds, and the resulting memories match the alpha session's hook predicate. I reproduced this through ingest_document with its real memory-record writer. Preserve the actual directory string when checking scope rather than trimming valid path characters.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 10m 39s —— View job


Review of PR #977 (review 1 of 3)

Summary: 1 Blocker and 3 Notes. Every covered write path checks the domain or directory it stores, and no tool argument can reopen the rule.

Blocker (B1): in seed_project (seed_project.py:125), the domain gets resolved a second time, in remember.py:89-90, when the directory is not a registered repository.

  • In the alias-collision case the PR set out to handle, seed_project purges the session's own seeded rows and only then does remember refuse the write.
  • That contradicts CHANGELOG.md:20-23 / :31-33 and the comment at seed_project.py:122-124.

Accepted: the rest of the PR (_write_scope.py and the other callers). B1 sits in mcp_server/handlers/seed_project.py.

Not done: I did not run pytest, ruff or check_craftsmanship.py: the runner needs approval to run Python, and the reviewers had no shell. B1 comes from reading the code and tracing the alias rules, not from running a test.

Verdict: block

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

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

I ran five reviews: security, code quality, tests, docs accuracy and performance. I checked every candidate Blocker against the code. All five tools the PR names (remember, seed_project, ingest_prd, ingest_document, ingest_codebase), plus anchor, supersedes_id, curation merges and replica refreshes, check the domain or directory they store before they write. No tool argument can reopen the rule. The security review found no way to write into another project through a covered path.

Blocker

B1. seed_project checks one domain and purges it, then remember resolves that name again and may refuse after the purge.

  • The claim this breaks: seed_project.py:122-124 and CHANGELOG.md:20-23 / :31-33 say the domain is resolved once, checked before anything is purged, and never resolved through the aliases a second time.
  • What the code does:
    • _parse_args sets D1 = remember._resolve_domain(root, raw) (seed_project.py:125).
    • It then hands D1 to remember as domain (:151).
    • When root is not a registered repository, remember._resolve_domain runs resolve_explicit_domain(D1) again (remember.py:89-90).
  • Why the second lookup can land elsewhere: name_to_canonical maps a name to itself only some of the time. domain_mapping.py:186-187 maps a repository's dir_name to that repository's canonical when the name is not already a key. So a family's canonical can map to another repository's canonical. This is the alias collision the PR set out to handle (_write_scope.py:64-70).
  • Traced example:
    • Repositories ai-architect-pipeline and ai-architect-prd share the canonical ai-architect.
    • A third repository has the remote zeta and is checked out in a folder named ai-architect, so ai-architect → zeta.
    • The session is in ai-architect-pipeline. It calls seed_project(directory=<folder that is not a repository>, domain="ai-architect-prd").
  • Result:
    • D1 = ai-architect is the session's own domain, so the check passes.
    • delete_memories_by_tag("seeded", domain="ai-architect") runs (seed_project.py:201).
    • The first remember call then resolves ai-architect → zeta and raises a ValidationError.
    • The session's seeded memories are gone and nothing replaces them.
  • Fix:
    • In _parse_args, check that _remember_domain(str(root), domain) returns domain again, and refuse a mismatch before anything is purged.
    • Or give remember a way to take a domain that is already resolved.
    • Also move the directory check (require_writable_directory(str(root), "seed_project")) into _parse_args, so it too runs before the purge.

Fix this →

Notes, no action before merge

  • backfill_memories passes a ValidationError from remember for another project's transcript straight up. Only OSError is caught (backfill_memories.py:238), so the run stops at that file, and the next run stops there again because the file is never marked as done. The CHANGELOG does not mention this change.
  • _worktree_roots (domain_mapping.py) reads worktrees/<id>/gitdir as an absolute path. With git's relative worktree paths (worktree.useRelativePaths, git 2.48 and later), the path resolves against the server's working directory, so such a worktree is not protected on the directory side. _dereference_worktree_gitdir already handles relative paths.
  • These channels predate this PR and are still open; they are worth a follow-up advisory:
    • SessionStart shows the latest checkpoint save to every project.
    • keyword_match triggers that remember extracts fire in every project's query_methodology.
    • The ingest_codebase docs pass supersedes an existing code:<name> row with no memory_writable check.

View job run
Verdict: block

…ule 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]>
@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

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


Review 2 of 3: PR #977

Summary:

  • B1 is fixed. seed_project now runs require_stable_domain and require_writable_directory in _parse_args, before the purge. A test on real git repositories asserts that nothing is purged.
  • No new Blocker in ba40c7a.
  • Three Notes:
    • A worktree whose gitdir holds a relative path is still not refused. This code is unchanged by the commit.
    • project_roots() is now recomputed at every check, which costs time on the bulk paths.
    • The seeded purge does not check each row before deleting it. This predates the PR.

Not done: pytest, ruff and check_craftsmanship.py did not run, because running Python needed approval on this runner. The tests were traced by hand.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

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

Scope: the one commit pushed after ff7b4af, which is ba40c7a. I ran five reviews on it (security, code quality, tests, docs, performance) and checked every candidate Blocker against the code.

Earlier Blocker

B1 (seed_project purged, then remember refused): fixed.

  • _parse_args now calls require_stable_domain(domain, _remember_domain(str(root), domain) or domain, ...) (seed_project.py:133-135). That is the same call remember._resolve_domain makes at write time with the domain it is handed (remember.py:81-90).
  • _parse_args also calls require_writable_directory(str(root), ...) (:138).
  • Both run before delete_memories_by_tag.
  • test_seed_project_refuses_a_domain_that_resolves_again_before_it_purges reproduces the alias collision on real git repositories and asserts nothing is purged.

Commit ba40c7a

I found no Blocker. The other changes in this commit hold up:

  • project_roots() is read at every check, so a worktree added while the server runs is seen.
  • The directory is no longer .strip()ped. Every writer stores the same string it checks.
  • The gitdir file is read with os.fsdecode.

Notes, no action before merge

  • _worktree_roots (domain_mapping.py:513, unchanged by this commit) still reads a relative gitdir (worktree.useRelativePaths, git 2.48+) against the working directory of the server. A folder above such a worktree is therefore not refused. Join the path to entry.parent, as _dereference_worktree_gitdir does.
  • project_roots() now lists .git/worktrees and resolves every root once per check, which means 2 to 4 times per remember. Bulk paths pay that cost per file (codebase_analyze, seed_project, remember_bulk), so computing the roots once per tool call would remove it.
  • The purge in seed_project (delete_memories_by_tag("seeded", domain=...)) does not run the per-row memory_writable check. In a shared domain, it can delete seeded rows another project wrote. This predates the PR.

Not done: I did not run pytest, ruff or check_craftsmanship.py, because running Python needed approval on this runner. The tests were traced by hand.

View job run
Verdict: approve

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

ℹ️ 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 +149 to +152
domain
for root, domain in project_roots().items()
for form in forms
if memory_matches_project(form, False, root) or root in project_ancestors(form)

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

P1 Badge Security: Detect unregistered repositories before authorizing writes

When the victim is outside the configured/default discovery roots, or is a new main repo created after _build_registry() was cached, reject the write. _reached_projects only checks project_roots(), which enumerates cached discovered repos. I initialized beta under CORTEX_DEV_ROOT and alpha elsewhere (and then created gamma after cache fill): both produced reached=[] and passed authorization, while memory_matches_project(victim, victim) was true. A beta caller can therefore persist attacker text with the victim's directory, and its SessionStart/auto-recall serves it. Unlike the prior linked-worktree report, fresh evidence is that whole main repos remain absent from the cached registry. Cortex hooks execute automatically.

SECURITY.md reference: SECURITY.md:L12-L14

Useful? React with 👍 / 👎.

…n 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]>
@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

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


Review 3 of 3: PR #977

Summary:

  • No Blocker. 3ca5645 is a refactor that changes no behaviour: project_ancestors already returns [] for None, only the first element was ever read, and _LOCAL is the same value as the literal False it replaces.
  • Accepted: the whole PR. No file or commit carries an open Blocker.
  • Three Notes:
    • The comment at _write_scope.py:50-51 ("a global one is refused before") gives the wrong reason for a correct check.
    • A worktree whose gitdir holds a relative path is still not refused.
    • Repositories that are not discovered, or that were created after the registry was cached, are not refused. Codex reported this and it is worth a follow-up.

Not done: I did not run pytest, ruff or check_craftsmanship.py. I traced the commit by hand.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

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

Scope: the one commit pushed after ba40c7a, which is 3ca5645. It changes 7 lines in mcp_server/handlers/_write_scope.py. I ran five reviews on it: security, code quality, tests, docs and performance.

Earlier Blockers

  • B1 (seed_project purged before remember refused): fixed in ba40c7a, as review 2 confirmed. 3ca5645 does not touch seed_project.py.
  • No other Blocker was open.

Commit 3ca5645: no Blocker

  • project_ancestors(directory or "")[:1] became project_ancestors(directory). Behaviour is unchanged:
    • project_ancestors already returns [] for None or "" (shared/project_scope.py:61-62).
    • Only ancestors[0] is read, so the slice never changed a result.
    • Relative paths, run:<id> labels and / are still filtered by the is_absolute() check.
  • The literal False became _LOCAL = False. It is the same value and carries a # source: comment.
  • Tests: the existing test_ghsa_7848_directory_context.py and test_ghsa_7848_directory_edges.py cases would kill a mutant that swaps _LOCAL for True. They also cover a None, empty or relative directory, and a directory above, inside or next to another project's root.
  • Cost: one ancestor list is built per check, which is microseconds. The loop over the roots is unchanged.

Accepted: the whole PR. That covers _write_scope.py, domain_mapping.py, the callers (remember, seed_project, ingest_prd, ingest_document, ingest_codebase, anchor) and the tests. No file or commit carries an open Blocker.

Notes, no action before merge

  • The comment at _write_scope.py:50-51 says "a global one is refused before". In remember, the directory check (remember.py:187) runs before global_scope (:345). The check is still correct, because a global memory is stored only under *, and * passes the directory rule. A suggested wording: "a global memory is stored only under CORTEX_CROSS_PROJECT_WRITES=*, which passes the directory rule".
  • _worktree_roots still resolves a relative gitdir (git 2.48+ worktree.useRelativePaths) against the working directory of the server. This is unchanged since review 2.
  • The Codex finding stays open as a follow-up. A repository outside the discovery roots, or one created after the registry was cached, is not in project_roots(), so a directory inside it is not refused.

Not done: I did not run pytest, ruff or check_craftsmanship.py. I traced the commit by hand against project_scope.py and the tests.

View job run
Verdict: approve

… 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

@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: 77e89b32e6

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +150 to +155
forms = {ancestors[0], Path(ancestors[0]).resolve().as_posix()}
return frozenset(
domain
for root, domain in project_roots().items()
for form in forms
if memory_matches_project(form, _LOCAL, root) or root in project_ancestors(form)

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 Cover symlinked project roots when checking ancestor directories

When a known repository is opened through a symlink outside the discovery roots, this check still allows cross-project injection through the symlink's parent. With known repositories /dev/alpha and /dev/beta, and /shortcuts/alpha -> /dev/alpha, a beta session can write with directory="/shortcuts" and a shared domain: _reached_projects returns an empty set, but the hooks' memory_matches_project("/shortcuts", False, "/shortcuts/alpha") returns true. I reproduced both the accepted authorization and matching hook predicate with real repositories; resolve_cwd("/shortcuts/alpha") correctly identifies the recipient as alpha. Resolving the supplied parent does not discover symlinked children, and project_roots() only includes discovered roots and worktrees. Align authorization and hook matching for these project-root aliases so an accepted ancestor-directory write cannot reach another project's session.

Useful? React with 👍 / 👎.

@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: 77e89b32e6

ℹ️ 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 on lines +512 to +513
if text:
found.append(_to_posix(Path(text).parent))

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: Validate linked-worktree ownership before mapping roots

When a lower-trust project session can write its own .git metadata and its repo is enumerated after the victim, validate each gitdir record before mapping it. A forged .git/worktrees/x/gitdir containing /victim/.git makes the dict comprehension assign the victim root to the attacker's domain, so require_writable_memory accepts a victim documents/prd row. The attacker can then anchor a guessed ID with an arbitrary reason; that text is prefixed, protected, and injected by SessionStart. Unlike the earlier stale-worktree report, fresh evidence is that a forged record poisons the live refresh itself. Cortex hooks run automatically with user filesystem access.

SECURITY.md reference: SECURITY.md:L12-L16

Useful? React with 👍 / 👎.

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Continued in #981.


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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants