fix(security): close D2 — descriptor schema runtime re-validation#55
Merged
Conversation
…-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)
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
loadDescriptorfallback 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 onui?.notifications?.error?.(...). Dynamicawait import(...)of the shared validator (Foundry-supported across the version range).Mockup: n/a — error notification surface only (sticky
ui.notifications.erroron drift; silent on success).What this changes
scripts/_descriptor-validator.mjs(NEW, 118 lines)validateDescriptor(descriptor, moduleJson). Same rules CI has always enforced, now reusable from both call sites.tools/check-package-descriptor.mjs(refactor)validateDescriptorfrom the shared module. CI-only concerns (file I/O +package.moduleJsonPathexists-on-disk check) stay in the script. Same CLI output.scripts/module.mjs_runtimeValidateDescriptor()to thereadyhook (alongside the existingsurfaceManifestRecoveryIfNeeded). Fetches the deployed descriptor + module.json, runs the shared validator, surfaces a stickyui.notifications.erroron drift. Non-fatal — module continues to run.tools/test-descriptor-validator.mjs(NEW, 226 lines)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
validateDescriptorfunction; the static-source test pins both imports so a future refactor can't accidentally split them.Test plan
node --test tools/test-*.mjspasses locally (250 baseline + 19 new = 269/269)node tools/check-package-descriptor.mjs→chronicle-package.json: OK (0 warnings)(unchanged CLI behavior post-refactor)Chronicle Sync | descriptor runtime check OKdebug message. Then hand-editmodules/chronicle-sync/chronicle-package.jsonto break a rule (e.g., setschemaVersion: 2); reload; verify the stickyui.notifications.errorappears and the console.error lists the specific drift.Tenet self-check
chronicle-foundry-moduleui.notifications.errorpattern with an actionable "reinstall from a fresh release" messageStop-and-flag
None encountered. Two notes:
package.moduleJsonPathexistence check stays in the CI script (it's meaningless at runtime).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