Skip to content

[BUG]: CopyOnWriteList compares equal to [], silently discarding empty-list hook payload modifications #135

Closed

Description

@metju-ac

Current Behavior

CopyOnWriteList compares equal to [] even when it contains data from its wrapped _original list. This causes hook payload policy handling to incorrectly drop a valid modified_payload when a plugin filters a list down to zero items.

Reproduced from mcp-context-forge while building a tool_post_list authorization plugin that filters a tool listing by the caller's IdP group membership. When a caller's groups grant them zero of the listed tools, the plugin correctly returns ToolPostListResult(modified_payload=ToolPostListPayload(items=[])), but CPEX treats the modified payload as "no effective change" and returns modified_payload=None — the gateway then falls back to serving the original, unfiltered list to a caller who should see nothing. Fail-open on exactly the case an authorization filter most needs to enforce.

Affected component: CPEX framework — cpex/framework/memory.py (CopyOnWriteList) and cpex/framework/hooks/policies.py (apply_policy).

Expected Behavior

CopyOnWriteList equality should compare the materialized logical sequence, not the empty base list storage.

cow = CopyOnWriteList(["tool-a", "tool-b", "tool-c"])

assert cow != []
assert cow == ["tool-a", "tool-b", "tool-c"]
assert [] != cow

For hook policy handling, if a plugin changes items=["tool-a", "tool-b", "tool-c"] to items=[], CPEX should treat that as an allowed items modification and preserve modified_payload, instead of silently discarding it.

Steps to Reproduce

  1. Create a CopyOnWriteList wrapping a non-empty original list, without ever writing to it:

    from cpex.framework.memory import CopyOnWriteList
    
    cow = CopyOnWriteList(["tool-a", "tool-b", "tool-c"])
  2. Compare it to an empty list:

    print(list(cow))
    print(len(cow))
    print(cow == [])
    print([] == cow)

    Actual output:

    ['tool-a', 'tool-b', 'tool-c']
    3
    True
    True
    
  3. Reproduce through hook policy handling:

    from cpex.framework import ToolPostListPayload, ToolPostListResult
    
    original_payload = ToolPostListPayload(
        server_id="srv-1",
        items=CopyOnWriteList(["tool-a", "tool-b", "tool-c"]),
    )
    
    modified_payload = ToolPostListPayload(
        server_id="srv-1",
        items=[],
    )

    When apply_policy() compares the items field, it evaluates new_val == old_val, where new_val == [] and old_val == CopyOnWriteList(["tool-a", "tool-b", "tool-c"]). Because CopyOnWriteList.__eq__ is not implemented and the base list storage is empty until _materialize() runs, the comparison returns True. apply_policy() then treats the field as unchanged and omits it from updates. If no other field changed, apply_policy() returns None, which drops the plugin's modified_payload and the gateway serves the caller the original, unfiltered items.

Logs / Error Output

Direct Python check against the installed CPEX package (0.1.1):

>>> from cpex.framework.memory import CopyOnWriteList
>>> original = ['tool-a', 'tool-b', 'tool-c']
>>> cow = CopyOnWriteList(original)
>>> len(cow)
3
>>> list(cow)
['tool-a', 'tool-b', 'tool-c']
>>> cow == []
True
>>> [] == cow
True
>>> cow._materialized
False

This manifested downstream as an authorization plugin's deny-everything decision being silently discarded: the gateway logged the plugin's own kept=0 filtering decision, then served the full, unfiltered tool list to the same request — indistinguishable from the plugin not running at all. Confirmed on production traffic across all users and all gated virtual servers for an extended window with zero denials recorded.

Environment

  • Version or commit: cpex==0.1.1; also present in main source structure
  • Runtime: Python 3.13
  • OS: Linux (also reproduced in a containerized mcp-context-forge deployment)

Additional Context (optional)

This is the same defect class as #54 (CopyOnWriteDict equality causes empty hook arg modifications to be dropped), fixed in #55 — but that fix only covered the Dict variant. CopyOnWriteList was never given the same treatment and still lacks __eq__/__ne__ today.

Activity

  1. added theissue type on Aug 4, 2026
  2. moved this from Backlog to In review in CPEXon Aug 4, 2026
  3. added this to the 0.1.3 milestone on Aug 4, 2026
  4. moved this from In review to In progress in CPEXon Aug 4, 2026
  5. moved this from In progress to Done in CPEXon Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    • Status
      Done

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions