Ghostlight/crates
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Meta Vens 0808c6a7fd Cut 14: refuse a stale tab's token, a token smuggled via bindings, and a leaked seed config name (PA.f178, PA.f179, PA.f180, seed refusal)
PA.f179 (runtime side): the `world.play` gate compared
`payload.answer_token.as_deref() != Some(question.token.as_str())`, which
forced `question.token`'s own `""` fallback (play.rs's own PA.f179 fix,
previous commit) into a value an attacker could literally send. Extracted
to `answer_token_admits(given, expected)`, which requires both sides
`Some` and equal — `expected: None` refuses unconditionally, including
against `given: None` or `given: Some("")`. Directly unit-tested
(`answer_token_admits_only_two_present_and_equal_tokens`), independent of
the HTTP round trip.

PA.f178: with no question currently open, `payload.answer_token` was
never read at all — a stale tab resubmitting its own spent token fell
through to `answers: None`, and its stale prose silently opened a *new*
turn, spending a real inference budget on an answer to a question that no
longer exists. A token arriving with no question open is now refused
outright ("that question is closed"), before `admit` is ever called.

PA.f180: `unwrap_bindings` merged the envelope's `bindings` object and a
button action's own sibling fields into one map with no memory of which
channel a field arrived on. `answerToken` is only ever legitimately an
action-sibling field (`eve.rs` never renders it as a captured control),
so a request built by hand that sent the *correct* token through
`bindings` instead would have been indistinguishable from the real thing.
`ACTION_ONLY_PAYLOAD_FIELDS` (currently just `answerToken`) is now
checked before the merge and refused if found inside `bindings`; every
other command's own real shape (action fields as siblings, captured
values inside `bindings`, arriving together) is unaffected.

Seed refusal leak: `seed_once`'s vault-root refusal named
`GHOSTLIGHT_SEED_VAULT_ROOT` directly to the player — a fact about this
process's own environment, not about the world (invariant 8). Replaced
with a player-safe "seeding is not available on this world"; the actual
env var name is still logged at `warn` for the operator. The other
refusals on this route (`seeding a world is its owner's lane`, `the
cognition organ is closed`, `VaultEvidenceSource::open`'s own errors,
which are deliberately vault-relative and never carry a path) were
checked and do not leak. `ControllerError`'s own `Display` (from
`runner.sweep`) is out of scope for this cut — see the Hands report for
why.

PA.f176: `eve_client_bridge_is_available()` checked only `node
--version` and that the vendored lowering's *committed* `dist/index.js`
is a file — always true regardless of whether either vendored package's
own `npm install` had ever run. Measured: with `node` present and no
`npm install` run anywhere, the old check reported "available" and the
four gated tests failed outright on `jsdom`; after installing only
`eve-browser-lowering`'s own deps, still failed on `ajv` (the sibling
`eve-contracts` package's own dependency, in its own `node_modules`,
which an install scoped to `eve-browser-lowering` never populates). The
check now spawns a real `node` process that does exactly what
`tools/eve_client_bridge.mjs` does at import time (resolve `jsdom`
through the lowering's own `package.json`, then dynamically import the
built `dist/index.js`, which is what actually pulls in `eve-contracts`
and `ajv`) and reports "available" only if that succeeds — cached behind
a `OnceLock` so the four gated tests share one real probe. All four
SKIPPED messages updated to name both vendored packages' own `npm
install`.

Tests added: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn,
an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted,
an_ordinary_answer_with_text_via_bindings_and_token_as_a_sibling_is_still_accepted
(negative proof for PA.f180), answer_token_admits_only_two_present_and_equal_tokens,
the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable.

Mutations verified by hand this pass (applied, confirmed the named test
failed, then reverted):
- removing the `payload.answer_token.is_some() && !is_awaiting_player`
  guard: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn
  fails (the stale resubmission is wrongly "accepted").
- reverting `answer_token_admits` to
  `given.unwrap_or_default() == expected.unwrap_or_default()`:
  answer_token_admits_only_two_present_and_equal_tokens fails (`None,
  None` and `Some(""), None` wrongly admit).
- removing the `ACTION_ONLY_PAYLOAD_FIELDS` check from `unwrap_bindings`:
  an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted fails
  (the smuggled token is wrongly "accepted").
- reverting the seed refusal to
  `format!("{SEED_VAULT_ROOT_ENVIRONMENT} is not configured")`:
  the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable
  fails.
- making `eve_client_bridge_is_available()` return `true` unconditionally
  once `dist/index.js` exists (the pre-PA.f176 shape): with both vendored
  packages' `node_modules` removed, the suite goes from 170 passed to 166
  passed / 4 failed with a real `Cannot find module 'jsdom'` node crash —
  reproducing the exact bug this cut fixes.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 170 passed,
with both vendored packages' own dependencies installed. With `node`
present and both packages' `node_modules` removed: 170 passed, 0 failed,
4 skipped (verified this pass by temporarily moving both directories
aside and restoring them afterward). A true "no `node` on PATH" run could
not be produced in this sandboxed workstation (multiple node shims exist
on PATH outside the vendored packages); the early `node_on_path` check
this cut did not touch is unchanged from before, and
`the_committed_eve_client_bridge_fixture_round_trips_through_api_router_with_no_node`
already proves the node-independent fixture-replay path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:30:43 +02:00
..
ghostlight Fix batch 8a-fix4: library id printer/parser PA.f113 2026-09-22 20:07:48 +02:00
ghostlight-dungeon Cut 14: refuse a stale tab's token, a token smuggled via bindings, and a leaked seed config name (PA.f178, PA.f179, PA.f180, seed refusal) 2026-09-23 04:30:43 +02:00
ghostlight-persona-projection Fix batch 8a-fix5: dispatch call body durable before its effects PA.f125-PA.f132 2026-09-22 21:31:30 +02:00