Skip to content

Security fix: keep GM-only page content out of saved Foundry journals - #147

Merged
keyxmakerx merged 4 commits into
mainfrom
claude/project-thread-rz71xm
Oct 4, 2026
Merged

keyxmakerx merged 4 commits into
mainfrom
claude/project-thread-rz71xm

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Requested by Key Maker · project thread

Fixes: none (follow-up to #141; the private tracker entry stays open until this is merged and released)
Security implication: security fix. GM-only text and pictures from Chronicle are no longer stored in Foundry journal pages at all, so players can't reach them, even on pages they own.
Consumer-verified: PUT /api/v1/campaigns/:id/entities/:id answers with the updated entity, entry_html included (internal/plugins/syncapi/api_handler.go, UpdateEntity → c.JSON(http.StatusOK, updated)); owner keys receive GM-only spans (internal/plugins/syncapi/egress_sanitize.go:107).
Foundry compatibility: not run in a live Foundry. Uses Foundry's own secret-block markup and a document-wide MutationObserver, the same approach #138 uses for GM pictures (v12 to v14). Live check: #142.
Mockup: n/a. Players see nothing new; see the GM-facing changes listed below.

What this changes

Before (after #141): GM-only text sat inside Foundry secret blocks. Foundry hides those blocks only when it draws the page. Every client still receives the page's text, and a player who owns a page sees its secret blocks. A Chronicle "edit" grant makes players owners in Foundry.

After: a synced page saved in Foundry holds a placeholder ("GM-only text, kept in Chronicle") where Chronicle has GM-only text or a GM-only picture. The real content never goes into the world's data.

  • The GM's screen shows the real content in place of each placeholder, read from Chronicle with the GM's API key.
  • When the GM edits the page in Foundry, sync puts the content back before sending it, so Chronicle keeps it.
  • Anything the GM types in a secret block, including words typed into a placeholder, goes to Chronicle as GM-only text. The page is then rewritten with placeholders.
  • On the first connect after updating, journals an older version left with GM-only text in the clear are pulled again, at most 20 per connect. Leftover field values in flags are removed without a request.

Why

#141 hid the text from players who don't own the page, but did not take it out of the data Foundry sends to every client. Holding only placeholders closes both remaining gaps at once.

How:

  • scripts/_gm-secrets.mjs:
    • hideSecrets replaces each secret block's content with a placeholder. The block's id is an HMAC-SHA-256 of its content under the GM's API key, so a player can't test a guess against it. The SHA-256 is pure JS, because Web Crypto isn't available on Foundry servers reached over plain HTTP.
    • restoreSecrets puts the content back before a push.
    • hasClearSecrets finds GM-only content still sitting in a page.
  • scripts/gm-secret-view.mjs fills placeholders on the GM's screen. It skips editors, keeps Foundry's own buttons, and never changes the saved page.
  • scripts/journal-sync.mjs:
    • _pullHtml handles every pull.
    • _entryForPush and _playerNotesForPush handle every push.
    • _hidePushedSecrets runs after pushes. It skips a journal while a newer edit is still waiting to be sent.
    • _hideStoredSecrets runs on connect.

Deviations and limits:

  • In Foundry's editor the GM sees placeholders, not the GM-only text, so it is edited in Chronicle. Reading it in Foundry works as before.
  • Foundry's Reveal button on a placeholder shows players only the placeholder.
  • If a placeholder's content can't be found, the page's text is not sent and the GM gets one notice. This happens when the GM-only text changed in Chronicle since the last pull, or when a GM client uses a different API key. The fix is a pull from the sync dashboard. GM clients should share the campaign's API key, as the setting's hint already says.
  • Unverified: whether Foundry's editor keeps a secret block's id when it is cut and pasted. If it doesn't, that page's text is held back until the next pull. Covered by the live check.

Test plan

  • node --test tools/test-*.mjs: 1243 pass, 0 fail. tools/test-gm-secrets.mjs adds placeholder tests:
    • the saved page holds no GM-only words, for every case;
    • round trips leave Chronicle unchanged;
    • ids are keyed and stable, and duplicate secrets are handled;
    • GM edits around, after, over and inside a placeholder;
    • an unknown or id-less placeholder blocks the push;
    • a placeholder written in another language still restores;
    • SHA-256 and HMAC match Node's crypto.
  • CHRONICLE_DIR=../Chronicle bench/run.sh journals: 14 pass, 0 fail, against Chronicle main. Two scenarios cover this change:
    • gm-secrets: the saved page holds no GM-only text; a GM edit keeps it GM-only in Chronicle; a secret block typed in Foundry, and words typed after a placeholder's label, reach Chronicle GM-only and then leave the saved page.
    • gm-secrets-old: a journal stored by an older version is hidden on the next connect.
  • Manual verification in Foundry (Check: GM-only text from Chronicle shows only to the GM in Foundry #142)
  • CI passes

Tenet self-check

  • T-B1 security: GM-only content from the owner key never enters world data that every client receives
  • T-B2 plugin isolation: module-only change
  • T-B3 production UI: n/a (no new UI; placeholder text is localized in lang/en.json)
  • T-B4 dual-audience docs: .ai.md file index and the CLAUDE.md conventions line updated

Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Abw8XBoqPMgbdjkmkuhCN2
@keyxmakerx keyxmakerx self-assigned this Oct 3, 2026
@keyxmakerx
keyxmakerx marked this pull request as ready for review October 3, 2026 17:40
…-rz71xm

# Conflicts:
#	CLAUDE.md
#	lang/en.json
@keyxmakerx
keyxmakerx merged commit 61b56bb into main Oct 4, 2026
3 checks passed
@keyxmakerx
keyxmakerx deleted the claude/project-thread-rz71xm branch October 4, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants