* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner Adds the simulation execution layer that consumes simulation-package.json and produces simulation-outcome.json for maritime chokepoint + energy/logistics theaters, closing the WorldMonitor → MiroFish handoff loop. Changes: - scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders, JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue with NX dedup lock, runSimulationWorker poll loop) - scripts/process-simulation-tasks.mjs: standalone worker entry point - proto: GetSimulationOutcome RPC + make generate - server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler - server/gateway.ts: slow tier for get-simulation-outcome - api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys - tests: 14 new tests for simulation runner functions * fix(simulation): address P1/P2 code review findings from PR #2220 Security (P1 #018): - sanitizeForPrompt() applied to all entity/seed fields interpolated into Round 1 prompt (entityId, class, stance, seedId, type, timing) - sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt - sanitizeForPrompt() + length caps applied to all LLM array fields written to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers) Validation (P1 #019): - Added validateRunId() regex guard - Applied in enqueueSimulationTask() and processNextSimulationTask() loop Type safety (P1 #020): - Added isOutcomePointer() and isPackagePointer() type guards in TS handlers - Replaced unsafe as-casts with runtime-validated guards in both handlers Correctness (P2 #022): - Log warning when pkgPointer.runId does not match task runId Architecture (P2 #024): - isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId - Call site simplified to pass theater directly Performance (P2 #025): - SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200 - Added max 3 initialReactions instruction to Round 1 prompt Maintainability (P2 #026): - Simulation pointer keys exported from server/_shared/cache-keys.ts - Both TS handlers import from shared location Documentation (P2 #027): - Strengthened runId no-op description in proto and OpenAPI spec * fix(todos): add blank lines around lists in markdown todo files * style(api): reformat openapi yaml to match linter output * test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage Two tests identified as missing during PR #2220 review: 1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId normalization added in the P1/P2 review pass. The existing tests only used the nested marketContext.topBucketId shape; this adds the flat root-field shape that arrives from the simulation-package.json JSON (selectedTheaters entries have topBucketId at root). 2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard, found:false NOT_FOUND return, found:true success path, note population on runId mismatch, and redis_unavailable error string. Follows the readSrc static-analysis pattern used elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test would require a test Redis instance).
3.9 KiB
status, priority, issue_id, tags
| status | priority | issue_id | tags | ||||
|---|---|---|---|---|---|---|---|
| complete | p1 | 019 |
|
runId flows unvalidated into Redis key construction and R2 path
Problem Statement
buildSimulationTaskKey(runId) and buildSimulationLockKey(runId) construct Redis keys via string concatenation using runId with no format validation. More critically, runId flows into buildSimulationOutcomeKey → buildTraceRunPrefix which constructs an R2 key of the form seed-data/forecast-traces/{year}/{month}/{day}/{runId}/simulation-outcome.json. A runId containing /../ path traversal sequences could produce an R2 key escaping the intended namespace.
Findings
F-1 (HIGH): R2 path uses runId directly in buildTraceRunPrefix:
// scripts/seed-forecasts.mjs — buildTraceRunPrefix (~line 4407)
`${basePrefix}/${year}/${month}/${day}/${runId}`
// runId containing '/../' produces: seed-data/forecast-traces/2026/03/24/../../../evil
F-2 (MEDIUM): Redis key construction via simple concatenation:
function buildSimulationTaskKey(runId) { return `${SIMULATION_TASK_KEY_PREFIX}:${runId}`; }
function buildSimulationLockKey(runId) { return `${SIMULATION_LOCK_KEY_PREFIX}:${runId}`; }
// No format guard — runId from CLI argv or queue member
F-3 (MEDIUM): ZADD member in task queue uses raw runId:
await redisCommand(url, token, ['ZADD', SIMULATION_TASK_QUEUE_KEY, String(Date.now()), runId]);
// If queue is poisoned, `listQueuedSimulationTasks` returns the malformed runId
// which then flows into all downstream key construction
Entry points: process.argv in process-simulation-tasks.mjs (operator-controlled, lower risk) and listQueuedSimulationTasks (queue member, higher risk if queue is ever written from an untrusted path).
Proposed Solutions
Option A: Validate runId format before any key operation (Recommended)
The existing parseForecastRunGeneratedAt (~line 4414) matches /^(\d{10,})/, suggesting runId values are timestamp-prefixed. Enforce this:
const VALID_RUN_ID = /^\d{13,}-[a-z0-9\-]{1,64}$/i;
function validateRunId(runId) {
if (!runId || !VALID_RUN_ID.test(runId)) return null;
return runId;
}
// In enqueueSimulationTask:
const safeRunId = validateRunId(runId);
if (!safeRunId) return { queued: false, reason: 'invalid_run_id_format' };
// In processNextSimulationTask, validate each queuedRunId before processing:
for (const rawId of queuedRunIds) {
const runId = validateRunId(rawId);
if (!runId) { console.warn('[Simulation] Skipping malformed runId:', rawId); continue; }
...
}
Effort: Small | Risk: Low
Option B: Sanitize R2 path components
Apply path.normalize and prefix-check on the constructed R2 key before write:
const key = buildSimulationOutcomeKey(runId, generatedAt);
if (!key.startsWith('seed-data/forecast-traces/')) throw new Error('R2 key escaped namespace');
Effort: Small | Risk: Low — defense-in-depth after Option A
Acceptance Criteria
enqueueSimulationTaskvalidatesrunIdmatches expected format before Redis writeprocessNextSimulationTaskvalidates eachrunIdfrom queue before key construction- R2 key is prefix-checked before write in
writeSimulationOutcome - Invalid
runIdproduces{ queued: false, reason: 'invalid_run_id_format' }not a silent key operation - Test:
runIdof"../../../evil"is rejected before Redis/R2 operations
Technical Details
- Files:
scripts/seed-forecasts.mjs—enqueueSimulationTask(~line 15636),buildSimulationTaskKey(~line 15633),processNextSimulationTask(~line 15682),writeSimulationOutcome(~line 15613) - Related:
buildTraceRunPrefix(~line 4407) — used by all trace artifact key builders
Work Log
- 2026-03-24: Found by compound-engineering:review:security-sentinel in PR #2220 review