Repository navigation
v2.0.0 — session ownership, tool-call approval, memory provenance
Five security-assessment findings closed. One breaking change — read it before upgrading.
pip install --upgrade shyftlabs-continuum⚠️ Breaking: session ownership is enforced by default
A session id names storage; it was never proof of who was calling. A caller could hand back any session id with a different user_id — or with none at all, which was the quietest path, because the check short-circuited before it could fire — and that history was read into their prompt, MCP tool state was restored, and their turns were written back into the other user's session.
SessionClient now compares the session's stored owner against a principal your application binds from a credential it verified:
from continuum.session import bind_principal
bind_principal(user_id) # at your auth boundary, from a verified credentialThe framework never derives a principal itself — from inside run(), a validated user id and a typed-in string are both just str.
Who is affected. Sessions created without a user_id have no owner, so anonymous and single-user deployments keep working untouched.
Upgrading without downtime: set SESSION_OWNERSHIP=audit → adopt bind_principal() → watch the session_ownership_* metric reach zero → remove the setting.
Multi-tenant deployments must also set SESSION_REQUIRE_PRINCIPAL=true or SESSION_HASH_IDS=true. enforce refuses a caller who gives the wrong identity, not one who gives none, and with plaintext ids anyone who knows a user id can construct that user's session id. Either setting closes it; the combination is pinned in the test suite.
This is why the release is 2.0.0 rather than 1.3.0: a >=1.0 pin now holds you at 1.2.0 until you choose to move.
A person can be required before a declared tool runs
AgentConfig(
tool_approval={"send_*", "*__transfer_funds"}, # fnmatch patterns
approval_handler=my_handler, # async → decision
)The gate sits between the policy check and argument injection, so the reviewer sees the arguments the tool will actually receive — something neither the policy gate nor tool-trust can see. The handler is yours: a CLI prompt, a web modal, Slack, a ticket. temporal_tool_approval() serves a reviewer who will answer in an hour.
Three outcomes — approved, denied, deferred. Every SDK failure path denies: a tool declared with no handler, a handler that raises, a handler that returns the wrong type, a timeout.
Nothing is remembered between runs, deliberately — a cached approval would authorise an execution the reviewer never saw. run_id and arguments are on the request if your app wants to build that itself.
Session ids are no longer derivable
They were built in plaintext from the identifiers they scope (u:{user_id}), so knowing a user id was enough to construct that user's session id. No leak required. SESSION_HASH_IDS=true derives them as an HMAC under SESSION_ID_SECRET, preserving determinism while keeping user identifiers out of Redis keys, log lines and traces. Off by default; existing sessions migrate on first access. Weak secrets are refused at startup.
Tool arguments are validated before the call
An MCP server's declared inputSchema was advertised to the model and then ignored, so the tool body was the first thing to notice a wrong shape — and a function fails where the bad value is first touched, meaning anything it did before that line had already happened. Required keys, unknown keys, and top-level type/enum are now enforced before the function is entered.
Deliberately not enforced: nested structure, empty {} property schemas, and open schemas. A partial validator that states its scope beats one whose gaps are found in production.
Long-term memory carries provenance
An indirect-injection payload could get itself "remembered" and replayed into every future session, laundering itself clean by crossing a session boundary. Data labels a run carries are now stamped onto every row it writes, and recalling that row re-taints the reading run — even on a turn that calls no tool. New AgentMemoryConfig.on_labeled_recall: fence (default), drop, or block.
Prompt-level fencing is defence in depth here, not the control: it was measured across four models, and gpt-4o-mini obeyed a planted instruction inside every envelope and wording tried.
pre_store_filter is a real gate
It used to write the memory and then delete it. Against Milvus that delete lost a race it could not win — mem0's delete() reads the row back first, Milvus hides recent writes behind Bounded consistency, and measured live the delete issued milliseconds after the write failed outright while the rejected fact stayed searchable for minutes. The gate now sits inside mem0's own _create_memory, so nothing is inserted to undo.
Two things to know: facts are offered one at a time, so a list[str] -> list[str] filter is called as filter([fact]) and membership decides — returning a rewritten string counts as a rejection. And it fails closed: a filter that raises has said nothing about the fact, so the fact is not written.
Full detail: CHANGELOG.md · PRs: #102, #103, #105