Skip to content

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

Merged
github-actions[bot] merged 26 commits into
mainfrom
fix/ghsa-7848-session-domain-r5
Nov 11, 2026
Merged

github-actions[bot] merged 26 commits into
mainfrom
fix/ghsa-7848-session-domain-r5

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.

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.

Continuation

Continues #977 with the same commits (head 77e89b3, approved, all checks green), plus one merge commit 8f495b8 that brings main into the branch. #977 could not be merged because main moved under it. The single conflict was in memory/mutation-equivalents.json, where both sides appended entries; both are kept (the _reached_projects entry of this branch, then the two docx_media_reader entries of #732). The file parses, and the registry tests and the test_ghsa_7848_* tests pass locally on the merged tree (24 passed; 84 passed).


Generated by Claude Code

cdeust and others added 24 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]>
…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]>
…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]>
… 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
Both sides added entries to memory/mutation-equivalents.json; both are kept.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYrKg46KKYpvLPXcW4M
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-11T04:37:58.806083Z 5d7f40d New commits
🔒 Security Review ✅ Completed 2026-11-11T04:37:40.969056Z 5d7f40d New commits
ℹ️ About Codex in GitHub

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

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

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

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 9m 12s —— View job


Review of PR #981 (1 of 3): blocked

  • Load /review-pr instructions
  • Gather diff and context
  • Run the five reviewer agents (quality, performance, tests, docs, security)
  • Check their findings against the code; classify Blockers and Notes
  • Post the review (top-level, inline)

The domain and directory checks hold up: each runs before its side effect, and the value checked is the value stored.

Blocker: prospective triggers made by remember still put caller text into other projects.

  • remember_helpers.py:609 turns phrases like TODO: into keyword_match triggers. For that trigger type, core/prospective.py:110-115 ignores the trigger's directory.
  • query_methodology and recall read every active trigger. So text written with no domain, or into the session's own project, shows up as a "Triggered Reminder" in any project whose first message shares a keyword.
  • This is the advisory's primitive. The PR's checks pass for such a write, so the CHANGELOG's "fixed" claim and the docstring at _write_scope.py:18 ("one of two channels") are wrong until this channel is closed.

Notes, no action before merge:

  • ingest_document.py grew from 297 to 305 lines, over the 300-line limit, and has no baseline entry. I could not run scripts/check_craftsmanship.py (it needs approval in this job); the count comes from reading the file. This PR's CI does not run that gate.
  • backfill_memories now stops at the first transcript from another project instead of skipping it.
  • codebase_analyze and ingest_document mark rows stale with no domain or directory filter, so they can mark another project's rows stale. This predates the PR and hides rows rather than adding text.

Accepted: everything except the trigger channel.

Verdict: block

Comment thread mcp_server/handlers/_write_scope.py Outdated

Anything else fails with the variable to set, never a silent redirect.

The domain is one of two channels. The SessionStart and auto-recall hooks select

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocker: domain and directory are not the only two channels. remember still has a third channel that crosses projects, so the same advisory primitive is still reachable.

  1. remember_helpers.py:609 calls write_post_store.extract_triggers.
  2. Any content with TODO:, remember to …, make sure … and similar phrases becomes a keyword_match row in prospective_memories.
  3. check_trigger (core/prospective.py:110-115) ignores target_directory for keyword_match.
  4. get_active_prospective_memories reads the whole table, with no domain or directory filter.
  5. query_methodology._get_fired_triggers (query_methodology.py:174-200, served at :292 / :335) then puts the attacker's text under "Triggered Reminders" in any project whose first message contains one of the keywords. recall_helpers.py:304 does the same for recall.

A write that passes every check in this PR therefore still lands in project P's context. Examples are a write with no domain, or one into the session's own project, such as remember(content="TODO: <instruction> before any deploy"). The CHANGELOG's claim that the advisory is fixed is wrong until that channel is closed.

A possible fix: make the fire-time check project-scoped. Fire keyword_match / entity_match triggers only when memory_matches_project(trigger.target_directory, False, session_root) holds. Alternatively, check each trigger with the same _write_scope rule.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in f14286f.

  • core.prospective.trigger_in_scope: a trigger fires only in a session whose project root is its target_directory or lies below it (memory_matches_project). A trigger without a directory reaches no session.
  • query_methodology._get_fired_triggers and recall_helpers.inject_triggered_memories 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.
  • The docstring of _write_scope.py and the CHANGELOG entry name this third channel.
  • ingest_document.py is back at 300 lines; check_craftsmanship.py reports OK.

Proof: tests_py/handlers/test_ghsa_7848_triggers.py, two git repositories. A TODO: written in beta does not fire in alpha's query_methodology nor in a recall from alpha, and still fires in beta. Both attack tests fail when the change to the two readers is removed.

The two other notes (backfill_memories stopping at the first foreign transcript, stale marking without a scope filter) are not changed here.


Generated by Claude Code

@claude

claude Bot commented Nov 11, 2026

Copy link
Copy Markdown

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

I ran five reviewers (quality, performance, tests, docs, security) and checked their findings against the code myself. The domain and directory checks hold up:

  • Every check runs before its side effect: before the purge in seed_project, before the store and wiki writes in ingest_*, before the update in anchor, and before the gate for supersedes_id.
  • The value that is checked is the value that is stored.
  • Replica refresh and curation filter their candidates through memory_writable.
  • CORTEX_CROSS_PROJECT_WRITES is read from the environment only.

Blocker

Prospective triggers still carry caller text into other projects (inline). This is the advisory primitive, still reachable through remember.

How it happens:

  1. remember_helpers.py:609 calls write_post_store.extract_triggers, which turns TODO:, remember to …, make sure … and similar phrases into keyword_match rows.
  2. core/prospective.py:110-115 ignores target_directory for that trigger type.
  3. Both stores read the whole table in get_active_prospective_memories.

Result: query_methodology._get_fired_triggers (query_methodology.py:174-200, served at :292 / :335) and recall_helpers.py:304 put that text under "Triggered Reminders" in any project whose first message or query shares a keyword. The write itself passes every check in this PR, for example one with no domain or one into the session own project.

Two statements are false until this is closed: _write_scope.py:18 says "the domain is one of two channels", and the CHANGELOG says the advisory is fixed.

Possible fix: scope trigger firing to the trigger target_directory with shared.project_scope.memory_matches_project. Add a test with two repositories: a TODO: written in alpha must not fire in the query_methodology of beta.
Fix this →

Notes, no action before merge

  • mcp_server/handlers/ingest_document.py grew from 297 to 305 lines and has no baseline entry, so python scripts/check_craftsmanship.py reports a new file-length violation. This PR CI does not run that gate.
  • backfill_memories passes each transcript cwd to remember and does not catch ValidationError (backfill_memories.py:191 → :340). The first transcript from another project now aborts the whole run instead of being skipped and reported.
  • These two predate the PR and only hide rows; they do not inject text:
    • codebase_analyze (load_existing_hashes / _mark_deleted) and ingest_document._retire_obsolete mark rows stale with no domain or directory filter, so they can mark rows of another project stale.
    • Running them through memory_writable would bring them under the same rule.

Accepted: everything except the trigger channel, which is the only open Blocker. It needs a change to core/prospective.py, handlers/query_methodology.py and handlers/recall_helpers.py, plus the docstring in _write_scope.py and the CHANGELOG entry.

View job run
Verdict: block

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

claude Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

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


Review of PR #981 (2 of 3): approved

  • Load /review-pr instructions
  • Check the earlier Blocker (prospective triggers) against f14286f: fixed
  • Run the five reviewer agents (security, quality, performance, tests, docs) on the commits after 8f495b8
  • Check their findings against the code; post the review (top-level)

The earlier Blocker is fixed. Prospective triggers now fire only in their own project:

  • trigger_in_scope lets a trigger fire only where its directory is served, and both query_methodology and recall check it.
  • create_trigger now runs require_writable_directory.
  • The new tests show a trigger written in one project firing there and not in the other.

None of the reviewers' findings is a Blocker.

I could not run pytest or ruff here because the commands need approval. The Pipeline check was still running when I posted the review.

Notes, no action before merge:

  • Recall's trigger injection searches every project for the memories it adds. This predates the PR.
  • create_trigger stores trigger types that check_trigger doesn't recognise, so its triggers never fire.
  • directory_match triggers are exempt from the new check, but nothing creates that type today.

Accepted: the whole PR.

Verdict: approve

@claude

claude Bot commented Nov 11, 2026

Copy link
Copy Markdown

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

The earlier Blocker is fixed (commit f14286f). Prospective triggers no longer fire in other projects.

  • core.prospective.trigger_in_scope lets a trigger fire only where its target_directory is served (memory_matches_project). A trigger with no directory, or a session with no root, fires nowhere.
  • Both readers that serve trigger text check it before check_trigger: query_methodology._get_fired_triggers and recall_helpers.inject_triggered_memories.
  • remember stores the directory it has already checked (remember.py:187 → extract_triggers).
  • create_trigger now defaults to the session directory and runs require_writable_directory.
  • test_ghsa_7848_triggers.py shows a beta TODO: firing in beta and not in alpha, through both query_methodology and recall.
  • The _write_scope.py docstring now names the trigger channel.
  • ingest_document.py is back to 300 lines.

I ran five reviewers (security, quality, performance, tests, docs) over the commits after 8f495b8, then checked their findings against the code. None of them is a Blocker.

I could not run pytest or ruff in this job because the commands need approval. The Pipeline run was still going when I posted this.

Notes, no action before merge

  • inject_triggered_memories (recall_helpers.py:308) calls search_fts with no directory_ancestors, so an in-scope trigger can pull in another project's memory. This predates the PR, and recall without a domain already searches every project.
  • create_trigger stores keyword, time, file or domain, but check_trigger only knows the *_match and time_based names. So its triggers never fire, and the CHANGELOG lines and docstring lines about how they fire describe nothing. This bug predates the PR.
  • trigger_in_scope exempts directory_match, which check_trigger decides with a substring test and a fallback to condition. No writer creates that type today. If one is added, scope it too, and the claim "a trigger stored without a directory no longer fires" would then need that exception.

Accepted: the whole PR.

View job run
Verdict: approve

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

claude Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

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


Review of PR #981 (3 of 3): approved

  • Load /review-pr instructions
  • List commits after f14286f: only 5d7f40d (a test and a mutation-equivalence entry)
  • Check earlier Blockers: none were open after review 2, and the trigger fix still holds
  • Run the five reviewer agents (security, quality, performance, tests, docs) on 5d7f40d
  • Check their findings and post the review (top-level)

5d7f40d does what it says:

  • The new alpha-first test kills the continue → break mutant, because both stores return triggers ORDER BY id.
  • The ^create_trigger: match kills the tool-name mutants.
  • The trigger_in_scope__mutmut_9 entry really is equivalent, because is_global is read only as if is_global:.

I did not run pytest or mutmut in this job.

Notes, no action before merge:

  • The create_trigger refusal test could also check that no row is stored after the refusal.

Accepted: the whole PR. No file or commit carries an open Blocker.

Verdict: approve

@claude

claude Bot commented Nov 11, 2026

Copy link
Copy Markdown

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

Nothing was open after review 2, and the trigger Blocker from review 1 is still fixed. The only commit since f14286f is 5d7f40d, which changes tests and the mutation registry only:

  • test_an_alpha_trigger_stored_first_does_not_stop_the_beta_one kills the continue → break mutant at query_methodology.py:189. Both stores return triggers ORDER BY id, so the alpha row comes first and is out of scope for beta.
  • The ^create_trigger: match kills the tool-name mutants. The message starts with f"{tool}: " (_write_scope.py:182).
  • The trigger_in_scope__mutmut_9 entry really is equivalent. memory_matches_project reads is_global only as if is_global: (project_scope.py:91).

I ran five reviewers (security, quality, performance, tests, docs) on 5d7f40d and checked what they reported against the code. None of it is a Blocker. I did not run pytest or mutmut here.

Notes, no action before merge

  • test_create_trigger_refuses_another_projects_directory could also check that no trigger row is stored after the refusal, in case the check is ever moved below _get_store().

Accepted: the whole PR, all 26 commits. No file or commit carries an open Blocker.

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 Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d7f40d4ad

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

source: GHSA-7848-qh48-mx2w, review 1 of pull request 981"""
if trigger.get("trigger_type") == "directory_match":
return True
return memory_matches_project(trigger.get("target_directory"), False, project_root)

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 Give auto-extracted reminders a default session directory

When remember is called without the optional directory, both MCP registration paths pass "", and write_post_store.extract_triggers persists that empty value as target_directory. This new predicate consequently suppresses every such reminder in both query_methodology and recall, even in the project where it was created: remember(content="TODO: rotate deploy credentials") still stores a trigger but it can never fire. Default newly auto-extracted triggers to the session directory, as this change already does for create_trigger, while retaining the rejection of genuinely unscoped historical triggers.

Useful? React with 👍 / 👎.

@github-actions
github-actions Bot merged commit 145aa75 into main Nov 11, 2026
18 checks passed
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