@@ -8,83 +8,178 @@ const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
88const workspace = resolve ( process . env . AGENT_TASK_WORKSPACE || process . cwd ( ) )
99const uploadPath = resolve ( process . env . AGENT_TASK_UPLOAD_PATH || join ( workspace , ".codebox" , "agent-task-upload" ) )
1010const requestPath = resolve ( process . env . AGENT_TASK_REQUEST_PATH || join ( workspace , ".codebox" , "agent-task-request.json" ) )
11+ const artifactsPath = join ( workspace , ".codebox" , "agent-task-artifacts" )
1112const secretValues = [ "OPENAI_API_KEY" , "MODEL_PROVIDER_SECRET_1" , "MODEL_PROVIDER_SECRET_2" , "MODEL_PROVIDER_SECRET_3" , "MODEL_PROVIDER_SECRET_4" , "MODEL_PROVIDER_SECRET_5" , "GITHUB_TOKEN" , "GH_TOKEN" , "ACCESS_TOKEN" , "EXTERNAL_PACKAGE_SOURCE_POLICY" ] . map ( ( name ) => process . env [ name ] ) . filter ( Boolean )
1213const runtimeSourceRoot = process . env . WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve ( process . env . WP_CODEBOX_RUNTIME_SOURCE_ROOT ) : ""
1314const runtimeSourcePrefix = process . env . WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve ( process . env . WP_CODEBOX_RUNTIME_SOURCE_PREFIX ) : ""
1415const runtimeSourceRoots = [ runtimeSourceRoot , runtimeSourcePrefix ] . filter ( Boolean )
15- const RUNTIME_SOURCE_TREE = / ( ^ | \/ ) ( p r e p a r e d - p l u g i n s | a g e n t s - a p i | a i - p r o v i d e r - f o r - o p e n a i ) ( \/ | $ ) /
16- const RUNTIME_SOURCE_FILE = / ^ ( a g e n t s - a p i \. p h p | p l u g i n \. p h p ) $ /
16+ const SOURCE_TREE = / ( ^ | \/ ) ( p r e p a r e d - p l u g i n s | p r e p a r e d - s o u r c e - p a c k a g e s | s o u r c e - p a c k a g e [ ^ / ] * ) ( \/ | $ ) / i
17+ const SOURCE_FILE = / \. (?: p h p | p h t m l | j s | m j s | c j s | j s x | t s | t s x ) $ / i
1718const RUNTIME_SOURCE_CONTENT = / (?: P l u g i n N a m e : | W P _ A g e n t s _ R e g i s t r y | O p e n A i P r o v i d e r ) /
1819
1920function redact ( value ) {
2021 return secretValues . reduce ( ( output , secret ) => output . split ( secret ) . join ( "[REDACTED]" ) , value )
2122}
2223
24+ function sanitizeText ( text ) {
25+ return sanitizeRuntimeSourceJson ( text , runtimeSourceRoots )
26+ }
27+
28+ function compactNativeInput ( text ) {
29+ const privateFields = new Set ( [ "source_package_root" , "component_contracts" , "extra_plugins" , "provider_plugins" , "runtime_overlays" , "prepared_sources" ] )
30+ const compact = ( value ) => {
31+ if ( Array . isArray ( value ) ) return value . map ( compact )
32+ const entry = record ( value )
33+ if ( ! Object . keys ( entry ) . length ) return value
34+ return Object . fromEntries ( Object . entries ( entry ) . flatMap ( ( [ key , item ] ) => privateFields . has ( key ) ? [ ] : [ [ key , compact ( item ) ] ] ) )
35+ }
36+ try {
37+ return `${ JSON . stringify ( compact ( JSON . parse ( sanitizeText ( text ) ) ) , null , 2 ) } \n`
38+ } catch {
39+ return sanitizeText ( text )
40+ }
41+ }
42+
2343function isPrivateRuntimePath ( value ) {
24- if ( ! runtimeSourceRoot || typeof value !== "string" ) return false
44+ if ( ! runtimeSourceRoots . length || typeof value !== "string" ) return false
2545 const path = resolve ( value )
26- const contained = relative ( runtimeSourceRoot , path )
27- return path === runtimeSourceRoot || ( contained !== ".." && ! contained . startsWith ( `..${ String . fromCharCode ( 47 ) } ` ) && ! isAbsolute ( contained ) )
46+ return runtimeSourceRoots . some ( ( root ) => {
47+ const contained = relative ( root , path )
48+ return path === root || ( contained !== ".." && ! contained . startsWith ( `..${ String . fromCharCode ( 47 ) } ` ) && ! isAbsolute ( contained ) )
49+ } )
2850}
2951
30- function sanitizeText ( text ) {
31- return sanitizeRuntimeSourceJson ( text , runtimeSourceRoots )
52+ function safeRelativeArtifactPath ( value ) {
53+ if ( typeof value !== "string" || ! value . trim ( ) || isAbsolute ( value ) ) return ""
54+ const path = value . replace ( / \\ / g, "/" ) . replace ( / ^ \. \/ / , "" )
55+ if ( path . split ( "/" ) . some ( ( part ) => ! part || part === "." || part === ".." ) ) return ""
56+ return path
3257}
3358
34- async function stageFile ( source , destination ) {
35- if ( isPrivateRuntimePath ( source ) ) {
36- throw new Error ( "Runtime source files must never be staged for artifact upload." )
37- }
59+ function sourceCategory ( path , absolutePath ) {
60+ if ( isPrivateRuntimePath ( absolutePath ) ) return "private-runtime"
61+ if ( SOURCE_TREE . test ( path ) ) return "source-tree"
62+ if ( SOURCE_FILE . test ( path ) ) return "source-file"
63+ return ""
64+ }
65+
66+ async function stageTextFile ( source , destination , options = { } ) {
3867 const metadata = await lstat ( source ) . catch ( ( ) => null )
3968 if ( ! metadata ?. isFile ( ) || metadata . size > MAX_UPLOAD_FILE_BYTES ) return false
40- if ( RUNTIME_SOURCE_TREE . test ( source ) || RUNTIME_SOURCE_FILE . test ( source . split ( "/" ) . pop ( ) || "" ) ) {
41- throw new Error ( "Prepared runtime plugin sources must never be staged for artifact upload." )
42- }
4369 const handle = await open ( source , constants . O_RDONLY | constants . O_NOFOLLOW ) . catch ( ( ) => null )
4470 if ( ! handle ) return false
4571 const openedMetadata = await handle . stat ( )
4672 const contents = openedMetadata . isFile ( ) && openedMetadata . size <= MAX_UPLOAD_FILE_BYTES ? await handle . readFile ( ) : null
4773 await handle . close ( )
4874 if ( ! contents || contents . includes ( 0 ) || ! isUtf8 ( contents ) ) return false
49- await mkdir ( resolve ( destination , ".." ) , { recursive : true } )
50- let text = contents . toString ( "utf8" )
51- if ( RUNTIME_SOURCE_CONTENT . test ( text ) ) {
52- throw new Error ( "Prepared runtime plugin source contents must never be staged for artifact upload." )
53- }
54- text = sanitizeText ( text )
75+ const text = redact ( options . compactNativeInput ? compactNativeInput ( contents . toString ( "utf8" ) ) : sanitizeText ( contents . toString ( "utf8" ) ) )
5576 assertNoRuntimeSourcePaths ( text , runtimeSourceRoots , "Runtime source paths must never be persisted in artifact uploads." )
56- await writeFile ( destination , redact ( text ) )
77+ if ( RUNTIME_SOURCE_CONTENT . test ( text ) ) throw new Error ( "Prepared runtime plugin source contents must never be staged for artifact upload." )
78+ await mkdir ( resolve ( destination , ".." ) , { recursive : true } )
79+ await writeFile ( destination , text )
5780 return true
5881}
5982
60- async function stageDirectory ( source , destination ) {
61- const metadata = await lstat ( source ) . catch ( ( ) => null )
62- if ( ! metadata ?. isDirectory ( ) ) return
63- for ( const entry of await readdir ( source , { withFileTypes : true } ) ) {
64- const entrySource = join ( source , entry . name )
65- const entryDestination = join ( destination , entry . name )
66- if ( entry . isDirectory ( ) ) await stageDirectory ( entrySource , entryDestination )
67- else if ( entry . isFile ( ) ) await stageFile ( entrySource , entryDestination )
83+ function record ( value ) {
84+ return value && typeof value === "object" && ! Array . isArray ( value ) ? value : { }
85+ }
86+
87+ function declarations ( request ) {
88+ return ( Array . isArray ( record ( request ) . artifacts ?. declarations ) ? record ( request ) . artifacts . declarations : [ ] )
89+ . flatMap ( ( declaration ) => {
90+ const entry = record ( declaration )
91+ return typeof entry . name === "string" && entry . name . trim ( )
92+ ? [ { name : entry . name . trim ( ) , type : typeof entry . type === "string" ? entry . type . trim ( ) : "" } ]
93+ : [ ]
94+ } )
95+ }
96+
97+ function declaredArtifactPaths ( result , allowed ) {
98+ const paths = new Set ( )
99+ const visit = ( value ) => {
100+ if ( Array . isArray ( value ) ) return value . forEach ( visit )
101+ const entry = record ( value )
102+ if ( ! Object . keys ( entry ) . length ) return
103+ const artifact = record ( entry . artifact )
104+ const path = safeRelativeArtifactPath ( artifact . path )
105+ const declared = allowed . some ( ( candidate ) => candidate . name === entry . name && ( ! candidate . type || candidate . type === entry . type ) )
106+ if ( path && declared ) paths . add ( path )
107+ Object . values ( entry ) . forEach ( visit )
108+ }
109+ visit ( result )
110+ return [ ...paths ] . sort ( )
111+ }
112+
113+ async function exclusions ( root , declaredPaths ) {
114+ const counts = new Map ( )
115+ const count = ( category ) => counts . set ( category , ( counts . get ( category ) || 0 ) + 1 )
116+ const visit = async ( directory ) => {
117+ const entries = await readdir ( directory , { withFileTypes : true } ) . catch ( ( ) => [ ] )
118+ for ( const entry of entries ) {
119+ const source = join ( directory , entry . name )
120+ const path = relative ( root , source ) . replaceAll ( "\\" , "/" )
121+ if ( entry . isDirectory ( ) ) await visit ( source )
122+ else if ( entry . isFile ( ) ) {
123+ const category = sourceCategory ( path , source )
124+ if ( category ) count ( category )
125+ else if ( ! declaredPaths . has ( path ) ) count ( "undeclared-artifact" )
126+ } else count ( "special-file" )
127+ }
68128 }
129+ await visit ( root )
130+ return [ ...counts . entries ( ) ] . sort ( ( [ left ] , [ right ] ) => left . localeCompare ( right ) ) . map ( ( [ category , count ] ) => ( { category, count } ) )
131+ }
132+
133+ function runtimeProvenance ( request ) {
134+ const sources = Array . isArray ( record ( request ) . runtime_sources ) ? record ( request ) . runtime_sources : [ ]
135+ return sources . flatMap ( ( source ) => {
136+ const entry = record ( source )
137+ if ( typeof entry . role !== "string" ) return [ ]
138+ const provenance = { role : entry . role }
139+ if ( record ( entry . source ) . type === "https_zip" ) {
140+ const sourceInfo = record ( entry . source )
141+ provenance . source = Object . fromEntries ( [ "type" , "url" , "sha256" , "archive_root" ] . flatMap ( ( key ) => typeof sourceInfo [ key ] === "string" ? [ [ key , sourceInfo [ key ] ] ] : [ ] ) )
142+ } else Object . assign ( provenance , ...[ "repository" , "revision" , "digest" ] . flatMap ( ( key ) => typeof entry [ key ] === "string" ? [ { [ key ] : entry [ key ] } ] : [ ] ) )
143+ return [ provenance ]
144+ } )
69145}
70146
71- async function assertNoPrivateRuntimePaths ( directory ) {
147+ async function finalScan ( directory ) {
72148 for ( const entry of await readdir ( directory , { withFileTypes : true } ) ) {
73149 const path = join ( directory , entry . name )
74- if ( entry . isDirectory ( ) ) await assertNoPrivateRuntimePaths ( path )
150+ const relativePath = relative ( uploadPath , path ) . replaceAll ( "\\" , "/" )
151+ if ( sourceCategory ( relativePath , path ) ) throw new Error ( "Prepared runtime plugin sources must never be persisted in artifact uploads." )
152+ if ( entry . isDirectory ( ) ) await finalScan ( path )
75153 else if ( entry . isFile ( ) ) {
76- const contents = await readFile ( path , "utf8" )
77- assertNoRuntimeSourcePaths ( contents , runtimeSourceRoots , "Runtime source paths must never be persisted in artifact uploads." )
78- if ( RUNTIME_SOURCE_TREE . test ( path ) || RUNTIME_SOURCE_FILE . test ( entry . name ) || RUNTIME_SOURCE_CONTENT . test ( contents ) ) throw new Error ( "Prepared runtime plugin sources must never be persisted in artifact uploads." )
79- }
154+ const bytes = await readFile ( path )
155+ const text = isUtf8 ( bytes ) ? bytes . toString ( "utf8" ) : ""
156+ assertNoRuntimeSourcePaths ( text , runtimeSourceRoots , "Runtime source paths must never be persisted in artifact uploads." )
157+ if ( RUNTIME_SOURCE_CONTENT . test ( text ) ) throw new Error ( "Prepared runtime plugin source contents must never be persisted in artifact uploads." )
158+ } else throw new Error ( "Only regular files may be persisted in artifact uploads." )
80159 }
81160}
82161
162+ const parseJsonOrEmpty = ( text ) => {
163+ try { return JSON . parse ( text ) } catch { return { } }
164+ }
165+ const request = parseJsonOrEmpty ( await readFile ( requestPath , "utf8" ) . catch ( ( ) => "{}" ) )
166+ const resultSource = join ( workspace , ".codebox" , "agent-task-workflow-result.json" )
167+ const result = parseJsonOrEmpty ( await readFile ( resultSource , "utf8" ) . catch ( ( ) => "{}" ) )
168+ const declaredPaths = new Set ( declaredArtifactPaths ( result , declarations ( request ) ) )
169+
83170await rm ( uploadPath , { recursive : true , force : true } )
84171await mkdir ( uploadPath , { recursive : true } )
85- await stageFile ( requestPath , join ( uploadPath , ".codebox" , "agent-task-request.json" ) )
86- for ( const path of [ ".codebox/agent-task-workflow-result.json" , ".codebox/native-agent-task-input.json" ] ) {
87- await stageFile ( join ( workspace , path ) , join ( uploadPath , path ) )
172+ await stageTextFile ( requestPath , join ( uploadPath , ".codebox" , "agent-task-request.json" ) )
173+ await stageTextFile ( resultSource , join ( uploadPath , ".codebox" , "agent-task-workflow-result.json" ) )
174+ await stageTextFile ( join ( workspace , ".codebox" , "native-agent-task-input.json" ) , join ( uploadPath , ".codebox" , "native-agent-task-input.json" ) , { compactNativeInput : true } )
175+ for ( const path of declaredPaths ) {
176+ const source = resolve ( artifactsPath , path )
177+ if ( relative ( artifactsPath , source ) . startsWith ( ".." ) || sourceCategory ( path , source ) ) {
178+ throw new Error ( "Declared reviewer artifacts must not reference source files or private runtime internals." )
179+ }
180+ await stageTextFile ( source , join ( uploadPath , ".codebox" , "agent-task-artifacts" , path ) )
88181}
89- await stageDirectory ( join ( workspace , ".codebox" , "agent-task-artifacts" ) , join ( uploadPath , ".codebox" , "agent-task-artifacts" ) )
90- await assertNoPrivateRuntimePaths ( uploadPath )
182+ await mkdir ( join ( uploadPath , ".codebox" , "agent-task-artifacts" ) , { recursive : true } )
183+ await writeFile ( join ( uploadPath , ".codebox" , "agent-task-artifacts" , "runtime-provenance.json" ) , `${ JSON . stringify ( { schema : "wp-codebox/agent-task-runtime-provenance/v1" , sources : runtimeProvenance ( request ) } , null , 2 ) } \n` )
184+ await writeFile ( join ( uploadPath , ".codebox" , "agent-task-artifacts" , "exclusions.json" ) , `${ JSON . stringify ( { schema : "wp-codebox/agent-task-upload-exclusions/v1" , exclusions : await exclusions ( artifactsPath , declaredPaths ) } , null , 2 ) } \n` )
185+ await finalScan ( uploadPath )
0 commit comments