@@ -26,7 +26,7 @@ import {
2626 unlinkSync ,
2727 statSync ,
2828} from 'node:fs' ;
29- import { resolve } from 'node:path' ;
29+ import { resolve , sep } from 'node:path' ;
3030import type { ScenarioPackage } from '../engine/types.js' ;
3131
3232/**
@@ -88,12 +88,37 @@ function stripHooks(scenario: ScenarioPackage): Record<string, unknown> {
8888 return rest ;
8989}
9090
91+ /**
92+ * Strict id validator. Scenario ids in this codebase are kebab-case
93+ * slugs produced by the compile-from-seed pipeline; values containing
94+ * path separators or `..` segments are not legitimate ids and would
95+ * let a caller write/read outside the compiled/ directory. The
96+ * persist + delete paths run this gate before touching the filesystem.
97+ */
98+ function isSafeScenarioId ( id : string ) : boolean {
99+ return / ^ [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 _ - ] { 0 , 127 } $ / . test ( id ) ;
100+ }
101+
102+ /**
103+ * Result of a successful persist. The on-disk value of `compiledAt`
104+ * is the canonical source of truth; in-memory callers reflect it back
105+ * into their own state via this return value rather than re-deriving
106+ * from `Date.now()` (which would drift between the disk record and
107+ * the runtime metadata map by however long the writeFileSync took).
108+ */
109+ export interface PersistResult {
110+ filePath : string ;
111+ meta : PersistedCompiledMeta ;
112+ }
113+
91114/**
92115 * Save a compile-from-seed scenario to disk. Caller passes the fully-
93116 * compiled scenario; we persist a hook-stripped copy plus metadata.
94117 * Idempotent on the same id (overwrites the previous file).
95118 *
96- * @returns Absolute path written, or `null` on filesystem failure (we
119+ * @returns A {@link PersistResult} carrying the path written + the
120+ * exact meta block stored on disk so callers can mirror it into
121+ * their own state, or `null` on validation/filesystem failure (we
97122 * swallow the error, log via console.warn, and let the in-memory
98123 * catalog continue to serve the live run; persistence is a best-
99124 * effort enhancement, not a critical path).
@@ -102,7 +127,11 @@ export function persistCompiledScenario(
102127 scenarioDir : string ,
103128 scenario : ScenarioPackage ,
104129 seedText : string | null ,
105- ) : string | null {
130+ ) : PersistResult | null {
131+ if ( ! isSafeScenarioId ( scenario . id ) ) {
132+ console . warn ( `[scenarios] refusing to persist scenario with unsafe id: ${ JSON . stringify ( scenario . id ) } ` ) ;
133+ return null ;
134+ }
106135 try {
107136 const dir = compiledDir ( scenarioDir ) ;
108137 mkdirSync ( dir , { recursive : true } ) ;
@@ -117,7 +146,7 @@ export function persistCompiledScenario(
117146 const filePath = resolve ( dir , `${ scenario . id } .json` ) ;
118147 writeFileSync ( filePath , JSON . stringify ( payload , null , 2 ) ) ;
119148 enforceCompiledCap ( dir , COMPILED_SCENARIOS_CAP ) ;
120- return filePath ;
149+ return { filePath, meta } ;
121150 } catch ( err ) {
122151 console . warn ( `[scenarios] persistCompiledScenario failed for ${ scenario . id } :` , err ) ;
123152 return null ;
@@ -191,13 +220,26 @@ function enforceCompiledCap(dir: string, cap: number): void {
191220
192221/**
193222 * Remove a persisted draft by id. Returns true when a file was actually
194- * deleted, false when no matching file existed (idempotent for callers
195- * that don't pre-check). Used by the future `/scenario/delete` admin
196- * surface and by tests; production server-app does not call this on
197- * the active path.
223+ * deleted, false when no matching file existed or the id failed
224+ * validation (idempotent for callers that don't pre-check). Used by
225+ * the future `/scenario/delete` admin surface and by tests; production
226+ * server-app does not call this on the active path.
227+ *
228+ * Path-traversal guard: rejects any id that doesn't match the strict
229+ * kebab-slug shape AND verifies the resolved file path stays inside
230+ * the compiled subdir before unlinking. Belt-and-suspenders against
231+ * future callers that might wire user input through this surface.
198232 */
199233export function deletePersistedCompiledScenario ( scenarioDir : string , id : string ) : boolean {
200- const filePath = resolve ( compiledDir ( scenarioDir ) , `${ id } .json` ) ;
234+ if ( ! isSafeScenarioId ( id ) ) return false ;
235+ const dir = compiledDir ( scenarioDir ) ;
236+ const filePath = resolve ( dir , `${ id } .json` ) ;
237+ // Resolve-then-prefix-check defends against any future relaxation
238+ // of isSafeScenarioId. If the resolved file isn't inside dir, the
239+ // id was crafted to escape — refuse to delete.
240+ if ( filePath !== resolve ( dir , `${ id } .json` ) || ! filePath . startsWith ( `${ dir } ${ sep } ` ) ) {
241+ return false ;
242+ }
201243 if ( ! existsSync ( filePath ) ) return false ;
202244 try {
203245 unlinkSync ( filePath ) ;
0 commit comments