Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Status
    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions