Skip to content

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

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

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

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.


Generated by Claude Code

cdeust and others added 15 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]>
@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-10T11:04:51.351850Z 48c73b9 New commits
🔒 Security Review ✅ Completed 2026-11-10T11:07:50.950520Z 48c73b9 New commits

Security findings

Finding details are still loading. Check the individual review comments.

ℹ️ 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 10, 2026 •

Copy link
Copy Markdown

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


PR #970 review (1 of 3)

Result: blocked, with three Blockers. All three come from one gap. The new check only looks at the domain field. The session-start and auto-recall hooks choose what to inject from directory_context, and nothing checks that field.

  1. remember(directory=<a folder above other projects>) passes the check, and the memory reaches every project under that folder.
  2. ingest_prd(content=…) stores protected memories under the folder above the server's working directory, so they reach every sibling project. ingest_document has the same problem when given a path.
  3. anchor, supersedes_id and curation merges decide whether a row is writable from its domain alone. That lets another project's code-ingest rows (domain code:*) be rewritten.

The review also lists three Notes that need nothing before merge. I didn't change any code.

While checking that commenting worked, I posted a stray "test" comment on the PR. I couldn't edit or delete it without approval, so please delete it by hand.

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

test

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review 1 of 3: PR #970, GHSA-7848-qh48-mx2w (head a78f6e67)

The domain side of the fix is correct. I traced every branch of remember._resolve_domain, the batch path, codebase_analyze through remember, the seed_project purge, the replica refresh and curation merges, supersedes_id ordering, and explicit versus inferred is_global. The stored domain is the checked domain on all of them. The alias-collision commit a78f6e67 does what it says for ingest_prd and ingest_document. The new tests are hermetic: they use real git repos under tmp_path and clear the registry cache.

The fix is still incomplete against the threat in the advisory. Other projects' sessions get memories through two channels:

  • query_methodology, which selects by domain;
  • the SessionStart, auto_recall and agent_briefing hooks, which select by directory_context. A memory is injected when its directory_context is the session's project root or any folder above it (mcp_server/shared/project_scope.py:47-95, mcp_server/hooks/session_start_fetch.py:32,47,71, mcp_server/hooks/auto_recall.py:164).

_write_scope only checks domain. directory_context is written unchecked, so caller-supplied text still reaches another project's session context. That is the cross-project prompt-injection path the advisory describes.

Blockers

1. remember with directory set to a folder above another project. (mcp_server/handlers/remember.py:182-184, :206)

  • Call: remember(content=<payload>, directory="/home/u/Developments"), with no domain or a shared one such as "notes".
  • resolve_cwd finds no .git above that folder and returns '', so the domain is empty or shared and the check passes.
  • directory_context is stored as "/home/u/Developments", the raw argument (remember.py:206, remember_helpers.py:536).
  • That folder is in project_ancestors() of every repo under it, so the payload appears in every sibling project's SessionStart hot memories and in auto_recall.
  • The PR blocks a directory inside the other project only because resolve_cwd maps it to that project's domain. A folder above it is not covered.

Fix this →

2. ingest_prd(content=…) writes protected memories to the folder above the session's project. (mcp_server/handlers/ingest_prd.py:328)

  • directory = str(Path(args.get("path") or ".").expanduser().resolve().parent). With content (no path), this is the parent of the server's working directory, e.g. ~/Developments.
  • The decision bullets are written with heat 0.8 and is_protected=True, and the summary with heat 0.9, all with that directory_context (:253, :363).
  • The default domain "prd" passes the check, so ingest_prd(content="## Decisions\n- <payload>") lands in every sibling project's SessionStart anchors and hot memories.
  • With path=<file inside project B>, directory_context becomes a folder inside B, while the domain stays "prd".
  • The PR body and CHANGELOG list ingest_prd as fixed. ingest_document takes directory_context from the file's folder (ingest_document.py:243) and has the same path variant.

Fix this →

3. anchor, supersedes_id and curation merges judge an existing row by domain alone. (mcp_server/handlers/_write_scope.py:149-167, anchor.py:148)

  • require_writable_memory and memory_writable only look at row.domain and is_global.
  • Some rows carry a shared, non-project domain but another project's directory_context. For example, ingest_codebase writes domain="code:<name>" with directory_context=<B root> (ingest_codebase.py:471, ingest_docs_content.py:66).
  • From project A, anchor(memory_id=<that row>, reason=<payload>) therefore:
    • rewrites the content to [ANCHOR: <payload>] …;
    • sets is_protected and heat 1.0;
    • surfaces the row in B's SessionStart anchors section.
  • This contradicts the CHANGELOG line "anchor, supersedes_id, curation merges … never change another project's … memory".

Fix this →

The three Blockers share one fix: route directory_context through _write_scope as well. Refuse a directory that is inside another known project, or is a folder above any known project root other than the session's, unless CORTEX_CROSS_PROJECT_WRITES allows it. Apply the same rule to the existing-row predicates.

Notes, no action before merge

  • seed_project.py:125: the domain is resolved again in remember when the root is not a known repo, and the or domain fallback keeps the raw name. remember re-checks, so nothing leaks, but the purge, the writes and the report can name different domains.
  • _write_scope.py:115: EVERY_PROJECT has no # source: comment, and line 63 uses the raw "*" literal before the constant is defined.
  • CORTEX_CROSS_PROJECT_WRITES is documented only in the CHANGELOG. The remember schema still says to set domain "when crossing project boundaries" (remember_schema.py:201-202), which is now refused.

The craftsmanship limits (file and function size) and the layer rules pass. I found no performance regression: the registry is cached and no subprocess runs on the write path.

(The "test" comment just above this one was posted by mistake while I checked that commenting worked; please ignore it.)

View job run

Verdict: block

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

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

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


Review 2 of 3: PR #970, GHSA-7848-qh48-mx2w (head 2bfceb72)

  • Listed the commits pushed after a78f6e67: one new commit, 2bfceb72
  • Checked the three Blockers from review 1
  • Ran five reviewer agents (security, code quality, tests, docs, performance) on 2bfceb72 and checked their findings against the code
  • Posted the review here. I couldn't use gh pr comment or write a file for it because this job's permissions block both.

Scope: the review-1 Blockers, and 2bfceb72. I did not re-audit unchanged code.

Review-1 Blockers

  1. remember with a directory above another project: fixed for registered repos whose root path is not a symlink. See Blocker 3 below for the symlink case.
  2. ingest_prd and ingest_document: fixed.
    • content and pipeline_id input is now stored under session_directory().
    • A folder taken from a file path is checked.
  3. Existing rows judged by domain alone: partly fixed. anchor, supersedes_id and curation merges now check directory_context. Replica refresh does not; see Blocker 1.

Blockers

1. Replica refresh never checks the directory. The commit says it does. (mcp_server/handlers/remember_helpers.py:388, :406-408)

  • The SELECT reads only id, domain, is_global.
  • memory_writable is then called with {"domain", "is_global"}, so directory_context is always "". _refused_directory("") returns None.
  • Example: a memory-replica row has a shared or empty domain, and a directory_context inside project B or in a folder above it. From project A, remember(tags=["memory-replica", "vpath:<that vpath>"], content=<payload>) passes the check, and the UPDATE at :426-441 rewrites that row's content.
  • auto_recall does not exclude replica rows, so B is served the payload.
  • The commit message and CHANGELOG both say "replica refreshes refuse a row whose directory belongs to another project". The tests don't cover replicas.

Fix this →

2. ingest_codebase still stores a caller-supplied directory_context unchecked. (mcp_server/handlers/ingest_codebase.py:451,471, ingest_docs_content.py:65-66, ingest_helpers.py:218)

  • This is the writer of the code:<name> rows that review-1 Blocker 3 named, and it is still outside the fix both Blockers shared: route directory_context through _write_scope.
  • directory_context is set to resolve(project_path). The domain code:<name> is never a project domain, so neither the domain check nor the directory check ever applies.
  • Example: ingest_codebase(project_path="~/Developments"). Every Markdown file under that folder is stored under /home/u/Developments. That includes a file the caller wrote into its own project, and READMEs of cloned repos. The protected graph memo (heat 1.0) is stored there too. All of it is served to every sibling project's SessionStart.
  • This contradicts the PR title and the module docstring, which says the rule covers "remember and the other writing tools" (_write_scope.py:3).

Fix this →

3. The directory check resolves the path, while the registry roots and the stored text are not resolved. (mcp_server/handlers/_write_scope.py:141-145, shared/domain_mapping.py:100,112)

  • _reached_projects calls Path(directory).resolve() and compares the result with project_roots().
  • Those roots are _to_posix(item) under ~/Developments or CORTEX_DEV_ROOT, never resolved. resolve_cwd misses for the same reason: _git_root resolves, but the keys of path_to_repo are unresolved.
  • remember stores the raw directory (remember.py:209), and the hooks compare that text literally (project_scope.py:72,95).
  • Example: ~/Developments is a symlink to /data/dev. remember(directory="/home/u/Developments") resolves to /data/dev/. No root starts with that and resolve_cwd returns '', so the write is allowed. The row is stored under /home/u/Developments, an ancestor of every sibling project's root.
  • The new test at test_ghsa_7848_directory_context.py:69 covers only the opposite case: a symlink as the input, with real roots.
  • Fix: check the unresolved text the hooks compare, with trailing slashes stripped. Compare it against the roots both as given and resolved.

Fix this →

Notes, no action before merge

  • ingest_prd.py:320-321: session_directory() itself is never checked. With CLAUDE_PROJECT_DIR unset and the server's working directory above the projects, content input still reaches every project. Repos missing from the registry (outside the dev roots) are likewise not protected by the directory rule.
  • _write_scope.py:141: an absolute directory with a NUL byte raises a raw ValueError from Path.resolve(), not a ValidationError. Nothing is written.
  • ingest_document.py:202 and :248 resolve the path twice, with an await in between. Resolve it once and pass the result on.

Everything else in 2bfceb72 I traced as correct:

  • allowance handling, lowercase domains, the session's own root and its subfolders, and worktrees;
  • the anchor, supersedes_id and curation-merge row checks, which read SELECT * / get_memory;
  • the layer rules and # source: comments.

No performance regression: the registry is lru_cached, and the row checks run on at most a few rows per write.

I could not run pytest, ruff or check_craftsmanship.py, because this job's permissions block running them. I changed no code.

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 Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bfceb7283

ℹ️ 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/remember_helpers.py Outdated
Comment on lines +406 to +408
if memory_writable(
{"domain": row.get("domain"), "is_global": row.get("is_global")}
)

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 Include directory scope when authorizing replica refreshes

When an existing replica has a shared or empty domain but a directory_context belonging to another project, this check incorrectly permits refreshing it: the SELECT omits directory_context, and the mapping passed to memory_writable drops it as well. A caller in beta can therefore submit the matching memory-replica and vpath: tags with an otherwise permitted directory/domain and overwrite the content of alpha's row, which remains eligible for alpha's directory-scoped auto-recall. The complete row correctly fails memory_writable, but this projection passes. Select directory_context and include it in the authorization check before updating the replica.

Useful? React with 👍 / 👎.

Comment thread mcp_server/handlers/_write_scope.py Outdated
Comment on lines +141 to +144
above = Path(directory).resolve().as_posix().rstrip("/") + "/"
below = {
domain for root, domain in project_roots().items() if root.startswith(above)
}

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 Compare directory and registered roots in matching path forms

When a discovery root such as CORTEX_DEV_ROOT or ~/code is a symlink, this comparison misses the projects beneath it: above resolves the symlink, but project_roots() retains the unresolved paths produced by _discover_repos. For example, with /home/user/code -> /mnt/repos containing alpha and beta, remember(directory="/home/user/code", domain="team-notes") passes the new checks without any allowance because _reached_projects returns empty. The hooks deliberately compare lexical paths, so that memory is nevertheless injected into sessions rooted at /home/user/code/alpha. Account for the registered lexical paths as well as resolved paths when determining which projects a directory reaches.

Useful? React with 👍 / 👎.

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

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

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


Review 3 of 3: PR #970, GHSA-7848-qh48-mx2w (head 48c73b9)

  • Listed the commits after 2bfceb72: f348537 and 48c73b9
  • Checked the three review-2 Blockers
  • Ran five reviewer agents (security, code quality, tests, docs, performance) on the new commits and checked their findings against the code
  • Posted the review here. gh pr comment was blocked by this job's command permissions, which reject a multi-line body.

Review-2 Blockers

  1. The replica refresh ignored the row's directory: fixed. remember_helpers.py:388 now selects directory_context, and memory_writable(row) gets the full row (:403). Every replica is filtered before writable[0] is chosen.
  2. ingest_codebase stored its directory unchecked: fixed. require_writable_directory(_resolved, "ingest_codebase") runs at ingest_codebase.py:465, before the store is opened or anything is written. The docs pass stores the same resolved string that is checked (ingest_docs_content.py:65-66). The background hook calls this same handler, so it gets the check too.
  3. The directory and the registry roots were compared in different forms: fixed.
    • project_roots() now lists every root both as discovered and as resolved.
    • _reached_projects compares the directory both as written and as resolved, through _same_branch.
    • I traced these edge cases:
      • alpha vs alphabet;
      • trailing slashes;
      • /;
      • .. segments.
    • The hooks match the stored text exactly, so text the check normalises differently is served to no session.

The review-2 Note on ingest_prd is also handled: session_directory() is now checked, so a server started above the projects is refused.

Blockers

None in f348537 or 48c73b9.

Notes, no action before merge

  • Symlinked dev root. With the dev root under a symlink (~/code -> /mnt/repos), a session can no longer write into its own project. resolve_cwd looks up the resolved git root, but path_to_repo keys are the unresolved paths (domain_mapping.py:267,325,347). So session_domain() is '', while _reached_projects(<own root>) is now {own}. That refuses remember(directory=…), ingest_prd(content=…) and ingest_codebase of the session's own repo; it fails closed. test_ghsa_7848_directory_context.py:85 asserts the lookup that misses. Index path_to_repo under both forms, as root_forms does.
  • Checkpoint channel, outside this diff. checkpoint(action="save") stores directory and free text unchecked (checkpoint.py:234). session_start_db.py:89-102 then shows the newest active checkpoint to every project's SessionStart, with no project filter. It is the same cross-project injection primitive; it belongs in a follow-up PR or advisory.
  • Replica test. The other-projects-directory replica case would also pass on 2bfceb72, because _ReplicaStore returns the whole fixture row whatever the SELECT asks for. Only the exact-SQL startswith assertion guards the fix.

Merge guidance

I accept the whole PR: the domain rule (a78f6e67 and earlier, approved in the #969 reviews), the directory rule (2bfceb72), and f348537 and 48c73b9. No file or commit carries an open Blocker. The symlinked-session refusal and the checkpoint channel can go to a refinement PR.

I could not run pytest, ruff or check_craftsmanship.py, because this job's permissions block running them. I changed no code.

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 Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48c73b974a

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

# or a label such as `run:<id>`, is served to no session.
if not Path(directory).is_absolute():
return frozenset()
forms = {Path(directory).as_posix(), Path(directory).resolve().as_posix()}

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 Normalize directory separators the same way as the hooks

On POSIX, a caller can bypass this check with directory="/repos\\alpha" and a shared domain while running in beta. Path treats the backslash as a literal character, so _reached_projects finds no project and permits the write, but shared.project_scope._normalize_path converts it to /repos/alpha when the hooks evaluate the stored directory_context. The injected content consequently reaches alpha's session. Normalize the hook-visible path form before checking its scope.

Useful? React with 👍 / 👎.

Comment on lines +153 to +157
for root, domain in project_roots().items()
if any(_same_branch(form, root) for form in forms)
}
# A linked worktree lies outside its repository's root and belongs to it.
return frozenset((reached | {resolve_cwd(directory)}) - {""})

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 Include linked-worktree roots when checking ancestor directories

If alpha has a linked worktree at /external-worktrees/alpha-feature, a beta session can write a memory under /external-worktrees with a shared domain. project_roots() contains only the discovered main repositories, and resolve_cwd on the worktree's parent returns empty, so this check permits the write. However, shared.project_scope.memory_matches_project serves that parent-scoped memory to sessions in alpha's worktree, preserving the cross-project injection path. Include registered repositories' linked-worktree roots in the ancestor comparison; resolving only the supplied directory handles paths inside a worktree but not above it.

Useful? React with 👍 / 👎.


def session_domain(environ: Mapping[str, str] = os.environ) -> str:
"""The domain of the project this server session runs in, or ''."""
return resolve_cwd(environ.get(SESSION_ENV) or os.getcwd())

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 Resolve the session identity through symlinked discovery roots

When the discovery root is a symlink, such as ~/code -> /mnt/repos, resolve_cwd returns empty even for the session's own repository: _git_root resolves the path, but the registry's path_to_repo retains the lexical discovery path. Meanwhile, the new project_roots() correctly recognizes that repository's directory through both forms. As a result, a session in ~/code/beta cannot remember into its own directory or ingest its own project without setting a cross-project allowance. Resolve the session identity against the same lexical/resolved root forms used by the directory check.

Useful? React with 👍 / 👎.

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Continued in #977.


Generated by Claude Code

@cdeust cdeust closed this Nov 10, 2026
auto-merge was automatically disabled November 10, 2026 14:49

Pull request was closed

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