@@ -67,6 +67,15 @@ export interface RunnerWorkspaceIntegritySnapshot {
6767 baseline : RunnerWorkspacePublicationFile [ ]
6868}
6969
70+ export interface RunnerWorkspaceIntegrityFailureEvidence {
71+ schema : "wp-codebox/runner-workspace-integrity-failure/v1"
72+ added : string [ ]
73+ modified : string [ ]
74+ deleted : string [ ]
75+ total : number
76+ truncated : boolean
77+ }
78+
7079/**
7180 * Promotes the canonical sandbox patch artifact into the checked-out workspace.
7281 * Artifact references are treated as locators only after containment and digest
@@ -157,7 +166,23 @@ export async function runnerWorkspaceIdentity(root: string): Promise<RunnerWorks
157166export async function verifyRunnerWorkspaceIntegrity ( snapshot : RunnerWorkspaceIntegritySnapshot ) : Promise < void > {
158167 const current = await snapshotWorkspace ( snapshot . workspaceRoot )
159168 if ( JSON . stringify ( current ) !== JSON . stringify ( snapshot . files ) ) {
160- throw new Error ( "Runner workspace changed after approval; refusing publication." )
169+ const approved = new Map ( snapshot . files . map ( ( file ) => [ file . path , file ] ) )
170+ const actual = new Map ( current . map ( ( file ) => [ file . path , file ] ) )
171+ const added = [ ...actual . keys ( ) ] . filter ( ( path ) => ! approved . has ( path ) ) . sort ( )
172+ const deleted = [ ...approved . keys ( ) ] . filter ( ( path ) => ! actual . has ( path ) ) . sort ( )
173+ const modified = [ ...approved . keys ( ) ] . filter ( ( path ) => actual . has ( path ) && JSON . stringify ( approved . get ( path ) ) !== JSON . stringify ( actual . get ( path ) ) ) . sort ( )
174+ const changed = [ ...added , ...modified , ...deleted ]
175+ const limit = 100
176+ const error = new Error ( `Runner workspace changed after approval; refusing publication. Changed paths: ${ changed . slice ( 0 , 10 ) . join ( ", " ) } ` ) as Error & { evidence ?: RunnerWorkspaceIntegrityFailureEvidence }
177+ error . evidence = {
178+ schema : "wp-codebox/runner-workspace-integrity-failure/v1" ,
179+ added : added . slice ( 0 , limit ) ,
180+ modified : modified . slice ( 0 , limit ) ,
181+ deleted : deleted . slice ( 0 , limit ) ,
182+ total : changed . length ,
183+ truncated : changed . length > limit ,
184+ }
185+ throw error
161186 }
162187}
163188
@@ -235,10 +260,7 @@ function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[]
235260
236261async function snapshotWorkspace ( root : string ) : Promise < RunnerWorkspacePublicationFile [ ] > {
237262 const output : RunnerWorkspacePublicationFile [ ] = [ ]
238- const { stdout } = await execFileAsync ( "git" , [ "ls-files" , "--cached" , "--others" , "--exclude-standard" , "-z" ] , { cwd : root , maxBuffer : MAX_PATCH_BYTES } )
239- const paths = stdout . split ( "\0" )
240- . filter ( ( path ) => path && path !== ".codebox" && ! path . startsWith ( ".codebox/" ) )
241- . sort ( ( left , right ) => left . localeCompare ( right ) )
263+ const paths = await workspaceSnapshotPaths ( root )
242264 for ( const path of paths ) {
243265 const absolute = resolve ( root , path )
244266 if ( ! pathIsWithinRoot ( absolute , root ) ) throw new Error ( `Runner workspace contains a denied path: ${ path } ` )
@@ -257,6 +279,31 @@ async function snapshotWorkspace(root: string): Promise<RunnerWorkspacePublicati
257279 return output
258280}
259281
282+ async function workspaceSnapshotPaths ( root : string ) : Promise < string [ ] > {
283+ try {
284+ const { stdout } = await execFileAsync ( "git" , [ "ls-files" , "--cached" , "--others" , "--exclude-standard" , "-z" ] , { cwd : root , maxBuffer : MAX_PATCH_BYTES } )
285+ return stdout . split ( "\0" )
286+ . filter ( ( path ) => path && path !== ".codebox" && ! path . startsWith ( ".codebox/" ) )
287+ . sort ( ( left , right ) => left . localeCompare ( right ) )
288+ } catch {
289+ const paths : string [ ] = [ ]
290+ async function visit ( directory : string ) : Promise < void > {
291+ for ( const entry of await readdir ( directory , { withFileTypes : true } ) ) {
292+ if ( [ ".git" , ".codebox" , "node_modules" , "vendor" ] . includes ( entry . name ) ) continue
293+ const absolute = resolve ( directory , entry . name )
294+ const path = relative ( root , absolute ) . replaceAll ( "\\" , "/" )
295+ if ( entry . isDirectory ( ) ) {
296+ await visit ( absolute )
297+ } else {
298+ paths . push ( path )
299+ }
300+ }
301+ }
302+ await visit ( root )
303+ return paths . sort ( ( left , right ) => left . localeCompare ( right ) )
304+ }
305+ }
306+
260307function validateAppliedWorkspace ( baseline : RunnerWorkspacePublicationFile [ ] , current : RunnerWorkspacePublicationFile [ ] , changed : RunnerWorkspaceChangedFile [ ] ) : void {
261308 const before = new Map ( baseline . map ( ( file ) => [ file . path , file ] ) )
262309 const after = new Map ( current . map ( ( file ) => [ file . path , file ] ) )
0 commit comments