Skip to content

fix(security): close D2 — descriptor schema runtime re-validation#55

Merged
keyxmakerx merged 2 commits into
mainfrom
claude/fm-sec-chunk-7
May 23, 2026
Merged

fix(security): close D2 — descriptor schema runtime re-validation#55
keyxmakerx merged 2 commits into
mainfrom
claude/fm-sec-chunk-7

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Cites: 2026-05-21-core-tenets §T-B1, §T-O4 (single source of truth — CI + runtime share one validator); reports/foundry/2026-05-22-fm-security-audit.md §1.7, §4 Chunk 7, §0.5 D2=(b); dispatches/foundry/FM-SEC-CHUNK-7.md
Security implication: closes D2 (descriptor schema drift undetected at runtime). CI catches drift on every PR; this PR adds runtime defense-in-depth that catches drift when CI is bypassed (hand-edited release zip, ad-hoc deployment, etc).
Consumer-verified: Chronicle-side loadDescriptor fallback documented per .ai.md:269-281; the audit's §1.7 already verified no drift between this module's check + Chronicle's expectations. C-SEC-CHUNK-4 (parallel Chronicle work) formalizes that alignment on the server side.
Foundry compatibility: v12 / v13 / v14 — uses fetch(<relative module path>) which Foundry's Electron runtime resolves to the module's static-asset directory. Optional chaining on ui?.notifications?.error?.(...). Dynamic await import(...) of the shared validator (Foundry-supported across the version range).
Mockup: n/a — error notification surface only (sticky ui.notifications.error on drift; silent on success).

What this changes

File Change
scripts/_descriptor-validator.mjs (NEW, 118 lines) Pure shared validator exporting validateDescriptor(descriptor, moduleJson). Same rules CI has always enforced, now reusable from both call sites.
tools/check-package-descriptor.mjs (refactor) Imports validateDescriptor from the shared module. CI-only concerns (file I/O + package.moduleJsonPath exists-on-disk check) stay in the script. Same CLI output.
scripts/module.mjs Adds GM-gated _runtimeValidateDescriptor() to the ready hook (alongside the existing surfaceManifestRecoveryIfNeeded). Fetches the deployed descriptor + module.json, runs the shared validator, surfaces a sticky ui.notifications.error on drift. Non-fatal — module continues to run.
tools/test-descriptor-validator.mjs (NEW, 226 lines) 19 tests: happy path (synthetic fixture + real on-disk descriptor), every error/warning rule, static-source integration check that CI script + module.mjs both reference the shared validator.

Why

The Foundry-side security audit (§1.7, §4 Chunk 7, §0.5 D2=(b)) flagged that the descriptor's runtime fallback in Chronicle (per .ai.md:269-281) means a malformed descriptor never surfaces as a hard error — silent drift is possible if CI is bypassed or a release is hand-edited. The Foundry-side runtime re-check closes that gap: same rules, run at the consumer at load time, surfaces clearly to the GM if drift detected.

The shared-validator pattern also honors T-O4 (single source of truth per concern) — if CI and runtime had two separate implementations of the same rules, the drift risk shifts inside this repo. Now both call sites import the same validateDescriptor function; the static-source test pins both imports so a future refactor can't accidentally split them.

Test plan

  • node --test tools/test-*.mjs passes locally (250 baseline + 19 new = 269/269)
  • node tools/check-package-descriptor.mjschronicle-package.json: OK (0 warnings) (unchanged CLI behavior post-refactor)
  • Tests cover: happy path (synthetic + real descriptor), every rule's error path, the rewriteFields warning, static-source integration
  • Manual verification in Foundry: launch a world; check the browser console for Chronicle Sync | descriptor runtime check OK debug message. Then hand-edit modules/chronicle-sync/chronicle-package.json to break a rule (e.g., set schemaVersion: 2); reload; verify the sticky ui.notifications.error appears and the console.error lists the specific drift.

Tenet self-check

  • T-B1 security: closes D2; defense-in-depth for the descriptor schema invariant
  • T-B2 plugin isolation: changes stay within chronicle-foundry-module
  • T-B3 production UI: drift is surfaced via the standard ui.notifications.error pattern with an actionable "reinstall from a fresh release" message
  • T-B4 dual-audience docs: shared-validator file + runtime-hook doc-comment + test preamble explain the design for both audiences
  • T-O4 single source of truth: CI + runtime now share one validator instead of two parallel implementations

Stop-and-flag

None encountered. Two notes:

  • CI script's behavior unchanged. The refactor moves validation rules into the shared module without changing what gets checked. The on-disk package.moduleJsonPath existence check stays in the CI script (it's meaningless at runtime).
  • Runtime check is GM-only to match surfaceManifestRecoveryIfNeeded's audience (only GMs can act on the "reinstall from a fresh release" instruction). Player sessions would surface a notification they couldn't act on.

Note on commit history

Two commits via MCP push_files (signing infra failure on chronicle-foundry-module — 8th confirmed reproduction). Squash-on-merge will collapse cleanly.


🤖 Cites dispatches/foundry/FM-SEC-CHUNK-7.md. Generated by Claude Code.


Generated by Claude Code

…-SEC-CHUNK-7 part 1)

NEW scripts/_descriptor-validator.mjs — pure validator shared between
CI (tools/check-package-descriptor.mjs) and the runtime defense-in-depth
check (added in the pair commit).

REFACTORED tools/check-package-descriptor.mjs to import the shared rules
from scripts/_descriptor-validator.mjs. CI-only concerns (file I/O +
package.moduleJsonPath-exists-on-disk check) stay in the script.

The chronicle-package.json output is unchanged: CI still validates the
same rules and reports the same warnings/errors.

Pair commit follows with the module.mjs runtime hook + the test file.

Cites: 2026-05-21-core-tenets §T-B1 §T-O4
       reports/foundry/2026-05-22-fm-security-audit.md §1.7, §4 Chunk 7, §0.5 D2=(b)
…M-SEC-CHUNK-7 part 2)

MODIFIED scripts/module.mjs — adds GM-gated _runtimeValidateDescriptor()
in the ready hook. Fetches the deployed chronicle-package.json + module.json
from the module's static-asset path, runs the shared validateDescriptor
helper. On drift: console.error per error + sticky ui.notifications.error
("...reinstall from a fresh release"). On success: silent (debug log only).
Failures are non-fatal — the module continues to run.

NEW tools/test-descriptor-validator.mjs — 19 tests covering:
  - Happy path (synthetic fixture + the REAL deployed descriptor)
  - Error paths for every validation rule (schemaVersion, package.id
    cross-reference, package.kind, missing fields, malformed endpoints,
    perCampaignSignedToken/zipContentRoot types)
  - Warning path (rewriteFields references non-existent module.json field)
  - Static-source integration: BOTH CI script and module.mjs import the
    shared validator (catches future refactor drift)

Test suite: 250 baseline + 19 new = 269/269 pass.
CI script: chronicle-package.json: OK (0 warnings) — unchanged behavior.

Cites: 2026-05-21-core-tenets §T-B1 §T-O4
       reports/foundry/2026-05-22-fm-security-audit.md §1.7, §4 Chunk 7, §0.5 D2=(b)
@keyxmakerx keyxmakerx merged commit 2ccbc42 into main May 23, 2026
1 check passed
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.

1 participant