fix: the Passage was an unbounded essence faucet

`passage_start` rewinds the rite to `listen` at any time, and PassagePanel
offers exactly that button after a resisted release. Every replayed layer
re-credited its essence, so one summon funded an endless loop — the 20/60s
limiter caps the rate, never the total. Measured at ~110 essence/minute,
indefinitely.

Each layer now pays the first time it opens for a presence and never again,
cleared only by a genuine summon. Re-walking still reveals; it just doesn't
mint. The frame reports what was ACTUALLY credited, so the UI's running
total can't drift from the ledger.

Three further fixes in the same handlers:
- judgment -> passage double-paid a crossing. The passage -> cross_over
  direction was already guarded; the reverse ran free, favor included.
- both handlers read `state.entity["id"]` AFTER their DB round-trip. The
  HTTP telemetry path drives the same SeanceState and can summon
  concurrently, so a crossing could mark the presence that just arrived.
  Pinned before the awaits.

tests/test_ws_passage.py is new, and covers the gap that let all of this
hide: test_passage.py tests the pure module, and nothing exercised these
handlers over a real connection. Efficacy proven by reverting the fix —
the replay test then reports "minted 280 extra essence".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 01:44:47 +00:00
parent 09089f01d5
commit a727858a62
2 changed files with 354 additions and 10 deletions

View File

@@ -163,6 +163,17 @@ class SeanceState:
passage_layer: str = passage.FIRST_LAYER
passage_lied: bool = False
passage_crossed: bool = False
# Which beats have ALREADY paid out for the current entity. Without
# this, essence is unbounded: `passage_start` rewinds the rite to
# `listen` at any time (and the UI offers exactly that button after a
# resisted release), so a client could walk listen/name/unbind/open for
# +28 essence, restart, and repeat forever off a single summon — the
# limiter caps the rate, not the total. A volatility collapse rewinds it
# the same way. So each layer pays the FIRST time it opens for a given
# presence and never again; re-walking it still reveals, still costs
# nothing already banked, but mints no new essence. Cleared only on a
# fresh summon, never by `passage_start` — that is the whole point.
passage_paid: set[str] = field(default_factory=set)
# Per-session RNG for `tell` frames — unseeded (a session's tells should
# vary run to run), but persistent across calls so the draw sequence
# isn't restarted on every single message.
@@ -572,7 +583,7 @@ async def _summon_locked(state: SeanceState) -> None:
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
_reset_passage(state)
_reset_passage(state, new_entity=True)
await state.send_queue.put(
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
@@ -915,6 +926,11 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
return
traits = state.entity.get("traits", {})
# Pinned before any await, for the same reason as in the Passage handler
# below: the device-telemetry path can re-summon on this SeanceState
# mid-handler, and re-reading `state.entity` after the DB round-trip would
# mark whichever entity arrived last as at_peace instead of the judged one.
entity_id = state.entity["id"]
outcome = judgment.judge_verdict(
verdict,
traits,
@@ -941,7 +957,7 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
credit_essence(user, outcome.essence_delta)
if outcome.consequence == "crossed_over":
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
entity_row = await db.get(Entity, uuid.UUID(entity_id))
if entity_row is not None:
entity_row.at_peace = True
@@ -958,6 +974,15 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
)
await db.commit()
if outcome.consequence == "crossed_over":
# Latch the same flag the Passage sets. The guard above already stops
# passage → cross_over double-paying; without this the REVERSE ran
# free: judge cross_over (+essence, +favor, entity at_peace), then walk
# the Passage to `release` on the same spirit and be paid the crossing
# a second time, favor included. One spirit, one crossing, whichever
# road got there first.
state.passage_crossed = True
await state.send_queue.put(
{
"type": "judgment_result",
@@ -982,13 +1007,25 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
# uses), the `at_peace` write, and the frames.
def _reset_passage(state: SeanceState) -> None:
def _reset_passage(state: SeanceState, *, new_entity: bool = False) -> None:
state.passage_layer = passage.FIRST_LAYER
state.passage_lied = False
state.passage_crossed = False
if new_entity:
# Only a genuinely new presence re-opens the purse. A `passage_start`
# rewind must not, or the rite becomes an essence faucet (see
# `passage_paid` on SeanceState).
state.passage_paid = set()
def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
def _passage_frame(
state: SeanceState, outcome: passage.PassageOutcome, essence: int
) -> dict:
# `essence` is what was ACTUALLY credited, which is the layer's value only
# the first time it opens for this presence (see `passage_paid`). The
# client sums this field into its running total, so sending
# `outcome.essence` on a replay would show the seeker essence they did not
# receive.
reveal = outcome.reveal
return {
"type": "passage_result",
@@ -1001,7 +1038,7 @@ def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
if reveal is not None
else None
),
"essence": outcome.essence,
"essence": essence,
"at_peace": outcome.at_peace,
"next_layer": outcome.next_layer,
# Deliberately NOT `outcome.lied` — that's this layer's own lie, and
@@ -1052,6 +1089,12 @@ async def _handle_passage_layer(state: SeanceState) -> None:
return
traits = state.entity.get("traits", {})
# Pinned HERE, before any await. `state.entity` can be replaced underneath
# this coroutine mid-handler: the HTTP device-telemetry path drives the
# same SeanceState and can summon (see `summon_lock`). Reading the id
# after the DB round-trip below would mark the WRONG entity at peace —
# the one that just arrived, not the one that actually crossed.
entity_id = state.entity["id"]
layer = state.passage_layer
# Both twists draw from the room's real physical noise, with distinct
# contexts so a volatility collapse and a deceptive reveal can never be
@@ -1074,8 +1117,15 @@ async def _handle_passage_layer(state: SeanceState) -> None:
if outcome.at_peace:
state.passage_crossed = True
# A layer pays once per presence. Replaying it — via `passage_start`, via
# a collapse rewind, or via a hand-rolled client spamming the frame —
# reveals the same thing again for free rather than minting essence again.
award = outcome.essence if layer not in state.passage_paid else 0
if outcome.result in ("opened", "crossed"):
state.passage_paid.add(layer)
item = None
if outcome.essence or outcome.at_peace:
if award or outcome.at_peace:
async with session_maker() as db:
# Locked — same read-modify-write race as every other essence
# credit in this file (see _reward_summon).
@@ -1083,8 +1133,8 @@ async def _handle_passage_layer(state: SeanceState) -> None:
select(User).where(User.id == state.user_id).with_for_update()
)
if user is not None:
if outcome.essence:
credit_essence(user, outcome.essence)
if award:
credit_essence(user, award)
if outcome.at_peace:
# Completing the Passage is the most compassionate act
# in the game, exactly as a correct cross_over verdict
@@ -1093,7 +1143,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
)
if outcome.at_peace:
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
entity_row = await db.get(Entity, uuid.UUID(entity_id))
if entity_row is not None:
entity_row.at_peace = True
item = roll_item_drop("judgment")
@@ -1108,7 +1158,7 @@ async def _handle_passage_layer(state: SeanceState) -> None:
)
await db.commit()
await state.send_queue.put(_passage_frame(state, outcome))
await state.send_queue.put(_passage_frame(state, outcome, award))
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})

View File

@@ -0,0 +1,294 @@
"""WS integration tests for the Passage handlers in app.ws.
Why this file exists: `tests/test_passage.py` covers app/passage.py, which is
pure — it decides outcomes and returns them. It cannot see the handler that
banks the essence those outcomes describe, and that handler is where an
unbounded essence faucet lived undetected through a 400-test suite: nothing
exercised `passage_start` / `passage_layer` over a real connection.
The faucet: `passage_start` rewinds the rite to `listen` at any time, and the
UI offers exactly that button after a resisted release (PassagePanel.tsx's
"walk it again"). Every replayed layer re-credited its essence, so one summon
funded an endless loop. The rate limiter caps how FAST that runs, never how
much it totals.
These tests are written against the exploit path, not against the
implementation: they drive the same frames a browser sends and then read the
ledger straight out of the database.
"""
import uuid
import pytest
import app.ws
from app.entities import fallback_profile
from app.inventory import SUMMON_ESSENCE_TRICKLE
from app.models.user import User
from app.passage import LAYERS, LAYER_ESSENCE
from app.rate_limit import RateLimiter
class FakeSpiritService:
async def mint_profile(
self, signature, channel, anomalies, language="en", entropy=None, sky=None
):
return fallback_profile(signature)
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
yield "here."
return gen()
def ambient_ready(self):
return False
async def _fake_synth(text, voice, profile, instability=0.0):
return b"RIFFfake wav bytes"
@pytest.fixture(autouse=True)
def _fake_spirits(monkeypatch):
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
# Module-level limiters are shared singletons that accumulate real hit
# counts across the whole session (precedent: test_ws_ritual_judgment.py).
# The passage limiter matters most here — the exploit these tests replay
# is deliberately high-volume, and the real 20/60s cap would mask it
# behind a rate limit rather than letting us observe the actual total.
for name in (
"summon_limiter",
"summon_ip_limiter",
"question_limiter",
"question_ip_limiter",
"fragment_limiter",
"fragment_ip_limiter",
"ritual_limiter",
"ritual_ip_limiter",
"judgment_limiter",
"judgment_ip_limiter",
"passage_limiter",
"passage_ip_limiter",
):
monkeypatch.setattr(app.ws, name, RateLimiter(max_requests=10_000, window_seconds=60))
def _no_twists(monkeypatch):
"""Pin both Passage draws to "no twist", leaving every other draw real.
A PassageDraw of 1.0 never fires, since both twists trigger on
`draw < chance`. Only the `passage:` contexts are pinned — blanketing
`veil_float` would also steer the summon and manifest draws, and these
tests are about the ledger, not about the room's noise.
"""
real = app.ws.veil_float
def selective(entropy, context, *args, **kwargs):
if context.startswith("passage:"):
return 1.0
return real(entropy, context, *args, **kwargs)
monkeypatch.setattr(app.ws, "veil_float", selective)
# The exploit never reaches `release`. Crossing latches `passage_crossed`,
# which correctly blocks replay — the faucet ran on the four beats BEFORE
# release, rewound by the "walk it again" button after a resisted release.
BEATS_BEFORE_RELEASE = len(LAYERS) - 1
def _read_until(ws, msg_type, max_frames=80, **match):
for _ in range(max_frames):
frame = ws.receive_json()
if frame.get("type") != msg_type:
continue
if all(frame.get(key) == value for key, value in match.items()):
return frame
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
def _login(sync_client, username):
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
return sync_client.cookies.get("qm_session")
def _ws_connect(sync_client, token):
return sync_client.websocket_connect(
"/ws/session", headers={"cookie": f"qm_session={token}"}
)
def _summon(ws):
ws.send_json({"type": "summon"})
frame = _read_until(ws, "entity")
_read_until(ws, "utterance", kind="greeting")
return frame
def _settle(ws):
"""Force a full round trip so any post-frame DB write has landed.
The essence credit runs before the frame is queued, but the sender task
drains that queue concurrently with the handler's remaining awaits. The
connection's message loop is sequential, so a pong is a hard guarantee
that the previous handler returned — not a poll-and-hope.
"""
ws.send_json({"type": "ping"})
_read_until(ws, "pong")
def _walk(ws, beats):
"""Send `beats` passage_layer frames, returning the result frames."""
frames = []
for _ in range(beats):
ws.send_json({"type": "passage_layer"})
frames.append(_read_until(ws, "passage_result"))
_settle(ws)
return frames
async def _essence(db_session, user_id):
db_session.expire_all()
user = await db_session.get(User, user_id)
return user.essence
def _user_id(sync_client, token):
return uuid.UUID(
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
)
# --- the faucet ------------------------------------------------------------
@pytest.mark.asyncio
async def test_replaying_the_rite_never_mints_essence_twice(
sync_client, db_session, monkeypatch
):
"""The exploit, executed literally.
Walk every layer, hit `passage_start` to rewind, walk them all again —
ten times over. The seeker must finish with exactly one rite's worth of
essence, because there was only ever one spirit.
Before the fix this credited the full ladder on every pass; with the
ladder summing to 53, ten passes paid 530 instead of 53.
"""
# No collapses and no crossing: this test is about the ledger, and a
# random rewind or an early `crossed` would make the totals depend on
# the draw rather than on the replay guard.
_no_twists(monkeypatch)
token = _login(sync_client, "faucet-walker")
user_id = _user_id(sync_client, token)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
_summon(ws)
baseline = await _essence(db_session, user_id)
ws.send_json({"type": "passage_start"})
_settle(ws)
_walk(ws, BEATS_BEFORE_RELEASE)
after_one_rite = await _essence(db_session, user_id)
for _ in range(10):
ws.send_json({"type": "passage_start"})
_settle(ws)
_walk(ws, BEATS_BEFORE_RELEASE)
after_ten_replays = await _essence(db_session, user_id)
earned = after_one_rite - baseline
assert earned > 0, "the rite paid nothing at all — the test proves nothing"
assert earned <= sum(LAYER_ESSENCE.values())
assert after_ten_replays == after_one_rite, (
f"replaying the rite minted {after_ten_replays - after_one_rite} extra essence; "
"the passage is an unbounded faucet again"
)
@pytest.mark.asyncio
async def test_the_reported_essence_matches_what_was_actually_credited(
sync_client, db_session, monkeypatch
):
"""The UI sums the frame's `essence` field into its running total.
If a replayed layer reports the layer's face value while the ledger
credits nothing, the seeker watches a total climb that their account
never receives — the most corrosive kind of bug in a game about trust.
"""
_no_twists(monkeypatch)
token = _login(sync_client, "honest-ledger")
user_id = _user_id(sync_client, token)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
_summon(ws)
baseline = await _essence(db_session, user_id)
ws.send_json({"type": "passage_start"})
_settle(ws)
first = _walk(ws, BEATS_BEFORE_RELEASE)
ws.send_json({"type": "passage_start"})
_settle(ws)
second = _walk(ws, BEATS_BEFORE_RELEASE)
final = await _essence(db_session, user_id)
assert sum(f["essence"] for f in first) == final - baseline
assert all(f["essence"] == 0 for f in second), (
"a replayed layer advertised essence it did not pay"
)
@pytest.mark.asyncio
async def test_a_genuinely_new_presence_reopens_the_purse(
sync_client, db_session, monkeypatch
):
"""The guard must not overshoot: each spirit is worth its own rite.
A `passage_paid` set that survived a summon would silently make every
spirit after the first worthless, which is a worse bug than the faucet.
"""
_no_twists(monkeypatch)
token = _login(sync_client, "second-spirit")
user_id = _user_id(sync_client, token)
with _ws_connect(sync_client, token) as ws:
_read_until(ws, "session")
_summon(ws)
baseline = await _essence(db_session, user_id)
ws.send_json({"type": "passage_start"})
_settle(ws)
_walk(ws, BEATS_BEFORE_RELEASE)
after_first = await _essence(db_session, user_id)
_summon(ws)
ws.send_json({"type": "passage_start"})
_settle(ws)
_walk(ws, BEATS_BEFORE_RELEASE)
after_second = await _essence(db_session, user_id)
first_rite = after_first - baseline
# The second summon also pays its own trickle, so compare the rite only.
second_rite = after_second - after_first - SUMMON_ESSENCE_TRICKLE
assert first_rite > 0
assert second_rite == first_rite, (
"a fresh presence did not re-open the purse — every spirit after the "
"first is worth nothing"
)