Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions src/agents/extensions/memory/encrypt_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,34 @@ def _unwrap(self, item: TResponseInputItem | EncryptedEnvelope) -> TResponseInpu
token = item["payload"].encode("utf-8")
plaintext = self.cipher.decrypt(token, ttl=self.ttl)
return cast(TResponseInputItem, _from_json_bytes(plaintext))
except (InvalidToken, KeyError):
return None

except (InvalidToken, KeyError):
return None

def _unwrap_for_pop(
self, item: TResponseInputItem | EncryptedEnvelope
) -> tuple[TResponseInputItem | None, bool]:
"""Unwrap a popped item and report whether authentication failed.

Fernet raises ``InvalidToken`` for both an expired token and a token that
cannot be authenticated with the configured key. ``pop_item`` needs to
distinguish those cases so a wrong key cannot drain recoverable history.
"""
if not _is_encrypted_envelope(item):
return cast(TResponseInputItem, item), False

try:
token = item["payload"].encode("utf-8")
plaintext = self.cipher.decrypt(token, ttl=self.ttl)
return cast(TResponseInputItem, _from_json_bytes(plaintext)), False
except KeyError:
return None, False
except InvalidToken:
try:
self.cipher.decrypt(token)
except InvalidToken:
return None, True
Comment on lines +237 to +240

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 Do not classify corrupt tokens as wrong-key ciphertext

When the newest envelope has a damaged, truncated, or tampered payload, the second decrypt raises the same InvalidToken as a wrong key, so this classifies the corrupt record as recoverable. pop_item() then restores it and returns None, permanently hiding any valid older items behind that corrupt tail instead of isolating it; the implementation needs an independent key identifier or another mechanism that can distinguish a key mismatch from record corruption.

AGENTS.md reference: AGENTS.md:L115-L115

Useful? React with 👍 / 👎.

return None, False

def _unwrap_valid_items(
self, encrypted_items: list[TResponseInputItem]
) -> list[TResponseInputItem]:
Expand Down Expand Up @@ -290,10 +315,19 @@ async def pop_item(
)
if not enc:
return None
item = self._unwrap(enc)
if item is not None:
return item

item, authentication_failed = self._unwrap_for_pop(enc)
if item is not None:
return item
if authentication_failed:
# Put back the exact item returned by the atomic backend pop.
# This avoids a get-then-pop race while refusing to drain history.
await _call_session_method(
self.underlying_session.add_items,
[cast(TResponseInputItem, enc)],
wrapper=wrapper,
)
Comment on lines +324 to +328

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 Restore popped ciphertext atomically

If another writer appends after the backend pop completes but before this add_items call, the old ciphertext is appended after the new item, so a correct-key reader observes reordered history and the next pop targets the wrong tail; if restoration fails, the ciphertext is lost outright. The pop and conditional restoration therefore need a backend-level atomic/transactional operation rather than two independently awaited mutations.

AGENTS.md reference: AGENTS.md:L117-L117

Useful? React with 👍 / 👎.

return None

async def clear_session(
self,
*,
Expand Down
36 changes: 36 additions & 0 deletions tests/extensions/memory/test_encrypt_session_wrong_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

import pytest

pytest.importorskip("cryptography")

from agents import SQLiteSession
from agents.extensions.memory.encrypt_session import EncryptedSession

pytestmark = pytest.mark.asyncio


async def test_wrong_key_pop_preserves_recoverable_ciphertext(tmp_path) -> None:
store = SQLiteSession("conversation", tmp_path / "history.db")
try:
correct = EncryptedSession(
session_id="conversation", underlying_session=store,
encryption_key="example-correct-key", ttl=3600,
)
wrong = EncryptedSession(
session_id="conversation", underlying_session=store,
encryption_key="example-wrong-key", ttl=3600,
)
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "follow-up"},
]
await correct.add_items(messages)
stored_before = await store.get_items()

assert await wrong.pop_item() is None
assert await store.get_items() == stored_before
assert await correct.get_items() == messages
finally:
store.close()