fix(scopes): guard scope membership config, move checks to the mutation point

Addresses a second review pass against 10655792.

1. Scope membership configuration was directly user-mutable.
   `PUT /sessions/{id}/peers/{peer_id}/config` had no scope guard at all, and
   `crud.set_peer_config` resolved the peer only to discard the row. Confirmed:
   posting `{"observe_others": false, "observe_me": true}` for a scope returned
   204 and persisted, which silently stops all fan-out into the scope and makes
   Honcho form a representation *of* a scope — neither of which is reachable
   through the facade. Deterministic, no race required. Now checked on the row
   `get_peer` already returns, so it costs nothing and cannot race.

2. Empty and over-long names were still 500s. Removing the charset pattern from
   `PeerSpec` fixed one trap but left its length bounds, and request-bound peer
   names carry no length limits of their own — so `peer_id: ""` or a 513-char
   name reached `PeerSpec(...)` and raised a raw pydantic ValidationError that
   the catch-all turned into a 500. `PeerSpec` now carries no constraints at all
   (matching its documented purpose) and every rule for a new name lives in
   `_validate_new_peer_names` on the insert path.

3. Resolved-row protection generalized. The previous pass applied it only to
   membership upserts, leaving check-then-use windows elsewhere: peer update
   could have a concurrently-created scope's configuration replaced wholesale
   (create-path validation does not fire for a peer that now exists), the chat
   observer get-or-create could resolve a fresh scope as its observer, and the
   generic session-peer removal could silently detach a scope from its sessions.
   Each now inspects the resolved peer immediately before acting; the redundant
   name-level guard on the update route is dropped in favor of the race-free one.

`remove_peers_from_session` grows an internal `_allow_scope_peers` flag because
the scopes facade ends membership through that same path and must not be blocked
by its own guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vineeth Voruganti 2026-07-29 13:52:20 -04:00
parent 10655792bd
commit 0d7270ac08
6 changed files with 184 additions and 13 deletions

View File

@ -28,6 +28,9 @@ from src.utils.types import GetOrCreateResult
logger = getLogger(__name__)
# Matches the peers.name CHECK constraint and PeerCreate's max_length.
PEER_NAME_MAX_LENGTH = 512
PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}"
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
@ -58,6 +61,12 @@ def _validate_new_peer_names(names: list[str]) -> None:
scopes_util.validate_no_scope_peer_names(
names, action="Use the scopes routes to create scopes."
)
too_long = sorted({n for n in names if len(n) > PEER_NAME_MAX_LENGTH})
if too_long:
raise ValidationException(
f"Peer name(s) must be at most {PEER_NAME_MAX_LENGTH} characters"
)
# RESOURCE_NAME_PATTERN's `+` already rejects the empty name.
offenders = sorted({n for n in names if not re.fullmatch(RESOURCE_NAME_PATTERN, n)})
if offenders:
raise ValidationException(
@ -386,6 +395,17 @@ async def update_peer(
)
honcho_peer = peers_result.resource[0]
# Refuse a real scope on the row just resolved, not on the name beforehand:
# this route replaces `configuration` wholesale, and a name-level check leaves
# a window in which a concurrently-created scope is resolved as existing (so
# create-path validation never fires) and then overwritten. An existing
# *unflagged* peer in the reserved namespace is an ordinary peer and passes.
if scopes_util.is_scope_peer(honcho_peer.name, honcho_peer.internal_metadata):
raise ValidationException(
f"Peer '{peer_name}' is a scope."
+ " Use the scopes routes to manage scopes."
)
needs_update = False
if peer.metadata is not None and honcho_peer.h_metadata != peer.metadata:

View File

@ -353,4 +353,6 @@ async def remove_session_from_scope(
workspace_name=workspace_name,
session_name=session_name,
peer_names={scope_peer_name(scope_name)},
# This *is* the supported path for ending scope membership.
_allow_scope_peers=True,
)

View File

@ -34,7 +34,7 @@ from src.utils.scopes import is_scope_peer, scope_peer_name
from src.utils.types import GetOrCreateResult
from src.vector_store import get_external_vector_store
from .peer import get_or_create_peers, get_peer
from .peer import get_or_create_peers, get_peer, reject_scope_peers
from .scope import SCOPE_MEMBERSHIP_CONFIG, get_or_create_scopes
from .workspace import get_or_create_workspace
@ -822,6 +822,8 @@ async def remove_peers_from_session(
workspace_name: str,
session_name: str,
peer_names: set[str],
*,
_allow_scope_peers: bool = False,
) -> bool:
"""
Remove specified peers from a session.
@ -831,16 +833,32 @@ async def remove_peers_from_session(
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to remove from the session
_allow_scope_peers: Internal. Set only by the scopes facade, which ends
scope membership through this same path and must not be blocked by
the guard below.
Returns:
True if peers were removed successfully
Raises:
ResourceNotFoundException: If the session does not exist
ValidationException: If any named peer is a scope
"""
# Verify session exists
await get_session(db, session_name, workspace_name)
# Scope membership is ended through the scopes routes, which also reconcile
# the scope's copies. Checked here rather than in the route so it is adjacent
# to the UPDATE below, leaving no window for a concurrently-created scope to
# be silently detached from its sessions.
if not _allow_scope_peers:
await reject_scope_peers(
db,
workspace_name,
peer_names,
action="Scope membership is managed via the scopes routes.",
)
# Soft delete specified session peers by setting left_at timestamp
update_stmt = (
update(models.SessionPeer)
@ -1227,10 +1245,18 @@ async def set_peer_config(
Raises:
ObserverException: If the update would exceed the observer limit
ValidationException: If the peer is a scope
"""
# First, get the session and peer to ensure they exist
await get_session(db, session_name, workspace_name)
await get_peer(db, workspace_name, peer_name)
peer = await get_peer(db, workspace_name, peer_name)
# A scope's membership config is the facade's, not the caller's: setting
# observe_others=false silently stops all fan-out into the scope, and
# observe_me=true makes Honcho form a representation *of* a scope, which
# never happens by design. Checked on the row just resolved above, so there
# is no check-then-use window and no extra query.
_reject_resolved_scope_peers([peer])
# Check if a SessionPeer entry already exists
stmt = (

View File

@ -30,7 +30,11 @@ from src.telemetry import prometheus_metrics
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
from src.utils.filter import extract_session_allowlist
from src.utils.schema_conversion import json_response_schema_to_pydantic
from src.utils.scopes import is_scope_peer_name, validate_no_scope_peer_names
from src.utils.scopes import (
is_scope_peer,
is_scope_peer_name,
validate_no_scope_peer_names,
)
from src.utils.search import search
from src.utils.types import embedding_call_purpose
@ -138,16 +142,13 @@ async def update_peer(
):
"""Update a Peer's metadata and/or configuration.
Three-way on the reserved namespace: a real scope is refused here (this route
replaces `configuration` wholesale, so it must never touch a facade-managed
peer); an existing *unflagged* peer that merely occupies the namespace is a
normal peer and updates fine; and a reserved-prefix name that does not exist
is refused by ``get_or_create_peers``' create-path validation rather than
being minted.
Three-way on the reserved namespace, all enforced inside ``crud.update_peer``
on the resolved row so there is no check-then-use window: a real scope is
refused (this route replaces `configuration` wholesale, so it must never touch
a facade-managed peer); an existing *unflagged* peer that merely occupies the
namespace is a normal peer and updates fine; and a reserved-prefix name that
does not exist is refused by create-path validation rather than being minted.
"""
await crud.reject_scope_peers(
db, workspace_id, [peer_id], action="Use the scopes routes to manage scopes."
)
updated_peer = await crud.update_peer(
db, workspace_name=workspace_id, peer_name=peer_id, peer=peer
)
@ -279,6 +280,15 @@ async def chat(
workspace_name=workspace_id,
peers=[schemas.PeerSpec(name=peer_id)],
)
# Re-check on the resolved row: the name-level check above ran before the
# peer was resolved, so a scope created in between would be picked up here
# as existing and used as the chat observer.
observer = peers_result.resource[0]
if is_scope_peer(observer.name, observer.internal_metadata):
raise ValidationException(
"No representation is formed of a scope, so a scope cannot be a "
+ "chat observer or target."
)
await peer_db.commit()
await peers_result.post_commit()

View File

@ -179,9 +179,16 @@ class PeerSpec(PeerBase):
author, an existing row including pre-``d429de0e5338`` legacy names containing
'.' and every ``scope.``-prefixed peer name. Re-validating those turns a lookup
into a raw pydantic ValidationError, i.e. an HTTP 500.
Carries **no** constraints at all, deliberately. Length limits here were the
same trap as the charset pattern: request-bound peer names (message authors,
session peer-map keys) have no length bound of their own, so an empty or
over-long name reached ``PeerSpec(...)`` and raised internally again a 500.
Every rule for a *new* name lives in ``crud.peer._validate_new_peer_names``,
which runs on the insert path only.
"""
name: Annotated[str, Field(min_length=1, max_length=512)]
name: str
metadata: _SanitizedMetadata | None = None
configuration: dict[str, Any] | None = None

View File

@ -965,3 +965,109 @@ async def test_resolved_scope_peer_rejected_at_membership_upsert(
),
workspace_name=test_workspace.name,
)
def test_set_peer_config_cannot_disable_a_scope(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""A scope's membership config belongs to the facade, not the caller.
`observe_others=false` would silently stop all fan-out into the scope, and
`observe_me=true` would make Honcho form a representation *of* a scope.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing = scope_peer_name(scope_name)
session_name = _create_session(client, test_workspace.name)
assert (
client.post(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}/sessions",
json={"session_ids": [session_name]},
).status_code
== 200
)
response = client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers/{backing}/config",
json={"observe_others": False, "observe_me": True},
)
assert response.status_code == 422, response.text
# Observer semantics intact
current = client.get(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers/{backing}/config"
)
assert current.status_code == 200
assert current.json() == {"observe_me": False, "observe_others": True}
async def test_unflagged_squatter_config_still_settable(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""The set_peer_config guard is flag-based, so a squatter is unaffected."""
test_workspace, _ = sample_data
squatter = scope_peer_name(str(generate_nanoid()))
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
assert (
client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={squatter: {}},
).status_code
== 200
)
response = client.put(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers/{squatter}/config",
json={"observe_others": False, "observe_me": True},
)
assert response.status_code == 204, response.text
def test_generic_session_remove_cannot_detach_a_scope(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Scope membership must end through the scopes routes, which reconcile."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
session_name = _create_session(client, test_workspace.name)
assert (
client.post(
f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}/sessions",
json={"session_ids": [session_name]},
).status_code
== 200
)
response = client.request(
"DELETE",
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json=[scope_peer_name(scope_name)],
)
assert response.status_code == 422, response.text
@pytest.mark.parametrize("bad_name", ["", "a" * 513])
def test_degenerate_peer_names_are_422_not_500(
client: TestClient, sample_data: tuple[Workspace, Peer], bad_name: str
):
"""Empty and over-long author names must not reach PeerSpec and 500.
Request-bound peer names carry no length bound of their own, so these used to
raise a raw pydantic ValidationError inside crud, which the catch-all handler
turned into a 500.
"""
test_workspace, _ = sample_data
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": bad_name, "content": "hello"}]},
)
assert response.status_code == 422, response.text