@@ -4,7 +4,7 @@ import { createWriteStream } from "node:fs"
44import { createHash } from "node:crypto"
55import { execFile , spawnSync } from "node:child_process"
66import { tmpdir } from "node:os"
7- import { basename , dirname , join , resolve } from "node:path"
7+ import { basename , dirname , join , relative , resolve } from "node:path"
88import { Readable } from "node:stream"
99import { pipeline } from "node:stream/promises"
1010import { promisify } from "node:util"
@@ -125,6 +125,37 @@ interface RecipeRunOptions {
125125 dryRun : boolean
126126}
127127
128+ interface RecipeArtifactEvidenceFile {
129+ path : string
130+ sha256 : string
131+ kind : string
132+ contentType : string
133+ }
134+
135+ interface RecipeArtifactEvidenceResult {
136+ artifactVerification ?: ArtifactBundleVerificationResult & {
137+ artifact : RecipeArtifactEvidenceFile
138+ strict : boolean
139+ }
140+ workspacePolicy ?: RecipeWorkspacePolicyArtifactResult & {
141+ artifact : RecipeArtifactEvidenceFile
142+ strict : boolean
143+ }
144+ }
145+
146+ interface RecipeWorkspacePolicyArtifactResult {
147+ schema : "wp-codebox/workspace-policy-artifacts/v1"
148+ passed : boolean
149+ checks : Array < {
150+ workspace : {
151+ target : string
152+ mode : "readonly" | "readwrite"
153+ metadata ? : Record < string , unknown>
154+ }
155+ result : WorkspacePolicyResult
156+ } >
157+ }
158+
128159interface RecipeValidateOptions {
129160 recipePath : string
130161 json : boolean
@@ -685,10 +716,41 @@ const workspaceRecipeJsonSchema: RecipeSchemaOutput["jsonSchema"] = {
685716 additionalProperties : false ,
686717 properties : {
687718 directory : { type : "string" } ,
719+ verify : { $ref : "#/$defs/artifactVerifier" } ,
720+ workspacePolicy : { $ref : "#/$defs/workspacePolicyArtifact" } ,
688721 } ,
689722 } ,
690723 } ,
691724 $defs : {
725+ artifactVerifier : {
726+ oneOf : [
727+ { type : "boolean" } ,
728+ {
729+ type : "object" ,
730+ additionalProperties : false ,
731+ properties : {
732+ enabled : { type : "boolean" } ,
733+ strict : { type : "boolean" } ,
734+ } ,
735+ } ,
736+ ] ,
737+ } ,
738+ workspacePolicyArtifact : {
739+ oneOf : [
740+ { type : "boolean" } ,
741+ {
742+ type : "object" ,
743+ additionalProperties : false ,
744+ properties : {
745+ enabled : { type : "boolean" } ,
746+ strict : { type : "boolean" } ,
747+ writableRoots : { type : "array" , items : { type : "string" } } ,
748+ hiddenPaths : { type : "array" , items : { type : "string" } } ,
749+ gitBacked : { type : "boolean" } ,
750+ } ,
751+ } ,
752+ ] ,
753+ } ,
692754 metadata : {
693755 type : "object" ,
694756 additionalProperties : true ,
@@ -1466,13 +1528,31 @@ async function runRecipe(options: RecipeRunOptions): Promise<RecipeRunOutput> {
14661528 await runtime . observe ( { type : "runtime-info" } )
14671529 await runtime . observe ( { type : "mounts" } )
14681530 artifacts = await runtime . collectArtifacts ( { includeLogs : true , includeObservations : true , previewHoldSeconds : options . previewHoldSeconds } )
1531+ const evidence = await finalizeRecipeArtifactEvidence ( artifacts , recipe , workspaceMounts , stagedFiles )
1532+ const strictFailure = recipeArtifactEvidenceFailure ( evidence )
14691533 const runtimeInfo = options . previewHoldSeconds ? await runtime . info ( ) : undefined
14701534 await releaseRuntime ( runtime , options . previewHoldSeconds , ( ) => cleanupRecipePreparedSources ( workspaceMounts , extraPlugins , stagedFiles ) )
14711535
14721536 const benchResultsList = executions
14731537 . filter ( ( execution ) => execution . command === "wordpress.bench" && execution . exitCode === 0 )
14741538 . map ( ( execution ) => parseBenchResults ( execution . stdout ) )
14751539
1540+ if ( strictFailure ) {
1541+ return {
1542+ success : false ,
1543+ schema : "wp - codebox / recipe - run / v1 ",
1544+ recipePath ,
1545+ runtime : runtimeInfo ?? await runtime . info ( ) ,
1546+ executions ,
1547+ stagedFiles : stagedFiles . map ( recipeRunStagedFile ) ,
1548+ siteSeeds ,
1549+ ...( benchResultsList . length === 1 ? { benchResults : benchResultsList [ 0 ] } : { } ) ,
1550+ ...( benchResultsList . length > 0 ? { benchResultsList } : { } ) ,
1551+ artifacts ,
1552+ error : strictFailure ,
1553+ }
1554+ }
1555+
14761556 return {
14771557 success : true ,
14781558 schema : "wp - codebox / recipe - run / v1 ",
@@ -1514,6 +1594,230 @@ async function runRecipe(options: RecipeRunOptions): Promise<RecipeRunOutput> {
15141594 }
15151595}
15161596
1597+ async function finalizeRecipeArtifactEvidence (
1598+ artifacts : ArtifactBundle ,
1599+ recipe : WorkspaceRecipe ,
1600+ workspaceMounts : PreparedWorkspaceMount [ ] ,
1601+ stagedFiles : PreparedStagedFile [ ] ,
1602+ ) : Promise < RecipeArtifactEvidenceResult > {
1603+ const result : RecipeArtifactEvidenceResult = { }
1604+ const verifier = normalizeArtifactToggle ( recipe . artifacts ?. verify )
1605+ const workspacePolicy = normalizeWorkspacePolicyArtifact ( recipe . artifacts ?. workspacePolicy )
1606+
1607+ if ( ! verifier . enabled && ! workspacePolicy . enabled ) {
1608+ return result
1609+ }
1610+
1611+ const evidenceDirectory = join ( dirname ( artifacts . reviewPath ) , "runtime-evidence" )
1612+ await mkdir ( evidenceDirectory , { recursive : true } )
1613+
1614+ const evidenceFiles : RecipeArtifactEvidenceFile [ ] = [ ]
1615+ if ( workspacePolicy . enabled ) {
1616+ const workspacePolicyPath = join ( evidenceDirectory , "workspace-policy.json" )
1617+ const policyResult = await buildRecipeWorkspacePolicyResult ( recipe , workspaceMounts , stagedFiles , workspacePolicy )
1618+ const policyFile = await writeRecipeEvidenceJson ( artifacts . directory , workspacePolicyPath , policyResult , "workspace-policy-result" )
1619+ artifacts . workspacePolicyPath = workspacePolicyPath
1620+ evidenceFiles . push ( policyFile )
1621+ result . workspacePolicy = {
1622+ ...policyResult ,
1623+ artifact : policyFile ,
1624+ strict : workspacePolicy . strict ,
1625+ }
1626+ }
1627+
1628+ if ( verifier . enabled ) {
1629+ const verificationPath = join ( evidenceDirectory , "artifact-bundle-verification.json" )
1630+ const placeholder = {
1631+ schema : "wp-codebox/artifact-bundle-verification/v1" ,
1632+ bundleDirectory : artifacts . directory ,
1633+ valid : false ,
1634+ violations : [ ] ,
1635+ status : "pending ",
1636+ }
1637+ const placeholderFile = await writeRecipeEvidenceJson ( artifacts . directory , verificationPath , placeholder , "artifact - bundle - verification ")
1638+ artifacts . artifactVerificationPath = verificationPath
1639+ evidenceFiles . push ( placeholderFile )
1640+ await updateRecipeArtifactEvidenceReferences ( artifacts , evidenceFiles )
1641+
1642+ const verification = await verifyArtifactBundle ( artifacts . directory )
1643+ const verificationFile = await writeRecipeEvidenceJson ( artifacts . directory , verificationPath , verification , "artifact - bundle - verification ")
1644+ const verificationFiles = evidenceFiles . map ( ( file ) => file . path === verificationFile . path ? verificationFile : file )
1645+ await updateRecipeArtifactEvidenceReferences ( artifacts , verificationFiles )
1646+ result . artifactVerification = {
1647+ ...verification ,
1648+ artifact : verificationFile ,
1649+ strict : verifier . strict ,
1650+ }
1651+ return result
1652+ }
1653+
1654+ await updateRecipeArtifactEvidenceReferences ( artifacts , evidenceFiles )
1655+ return result
1656+ }
1657+
1658+ function recipeArtifactEvidenceFailure ( evidence : RecipeArtifactEvidenceResult ) : RunOutput [ "error "] | undefined {
1659+ if ( evidence . artifactVerification ?. strict && ! evidence . artifactVerification . valid ) {
1660+ return {
1661+ name : "ArtifactVerificationError" ,
1662+ code : "artifact-verification-failed" ,
1663+ message : `Artifact verification failed with ${ evidence . artifactVerification . violations . length } violation${ evidence . artifactVerification . violations . length === 1 ? "" : "s" } .` ,
1664+ }
1665+ }
1666+
1667+ if ( evidence . workspacePolicy ?. strict && ! evidence . workspacePolicy . passed ) {
1668+ const violations = evidence . workspacePolicy . checks . reduce ( ( count , check ) => count + check . result . violations . length , 0 )
1669+ return {
1670+ name : "WorkspacePolicyError" ,
1671+ code : "workspace-policy-failed" ,
1672+ message : `Workspace policy failed with ${ violations } violation${ violations === 1 ? "" : "s" } .` ,
1673+ }
1674+ }
1675+
1676+ return undefined
1677+ }
1678+
1679+ function normalizeArtifactToggle ( value : boolean | { enabled ?: boolean ; strict ?: boolean } | undefined ) : { enabled : boolean ; strict : boolean } {
1680+ if ( value === undefined || value === false ) {
1681+ return { enabled : false , strict : false }
1682+ }
1683+ if ( value === true ) {
1684+ return { enabled : true , strict : false }
1685+ }
1686+ if ( value && typeof value === "object" ) {
1687+ return { enabled : value . enabled !== false , strict : value . strict === true }
1688+ }
1689+ return { enabled : false , strict : false }
1690+ }
1691+
1692+ function normalizeWorkspacePolicyArtifact ( value : boolean | { enabled ?: boolean ; strict ?: boolean ; writableRoots ?: string [ ] ; hiddenPaths ?: string [ ] ; gitBacked ?: boolean } | undefined ) : {
1693+ enabled : boolean
1694+ strict : boolean
1695+ writableRoots : string [ ]
1696+ hiddenPaths : string [ ]
1697+ gitBacked : boolean
1698+ } {
1699+ if ( value === undefined || value === false ) {
1700+ return { enabled : false , strict : false , writableRoots : [ "." ] , hiddenPaths : [ ] , gitBacked : false }
1701+ }
1702+ if ( value === true ) {
1703+ return { enabled : true , strict : false , writableRoots : [ "." ] , hiddenPaths : [ ] , gitBacked : false }
1704+ }
1705+ if ( value && typeof value === "object" ) {
1706+ return {
1707+ enabled : value . enabled !== false ,
1708+ strict : value . strict === true ,
1709+ writableRoots : Array . isArray ( value . writableRoots ) && value . writableRoots . length > 0 ? value . writableRoots : [ "." ] ,
1710+ hiddenPaths : Array . isArray ( value . hiddenPaths ) ? value . hiddenPaths : [ ] ,
1711+ gitBacked : value . gitBacked === true ,
1712+ }
1713+ }
1714+ return { enabled : false , strict : false , writableRoots : [ "." ] , hiddenPaths : [ ] , gitBacked : false }
1715+ }
1716+
1717+ async function buildRecipeWorkspacePolicyResult (
1718+ recipe : WorkspaceRecipe ,
1719+ workspaceMounts : PreparedWorkspaceMount [ ] ,
1720+ stagedFiles : PreparedStagedFile [ ] ,
1721+ policy : { writableRoots : string [ ] ; hiddenPaths : string [ ] ; gitBacked : boolean } ,
1722+ ) : Promise < RecipeWorkspacePolicyArtifactResult > {
1723+ const checks = [ ]
1724+ for ( const workspace of workspaceMounts . filter ( ( mount ) => mount . mode === "readwrite" ) ) {
1725+ checks . push ( {
1726+ workspace : {
1727+ target : workspace . target ,
1728+ mode : workspace . mode ,
1729+ metadata : workspace . metadata ,
1730+ } ,
1731+ result : await checkWorkspacePolicy ( {
1732+ workspaceRoot : workspace . source ,
1733+ writableRoots : policy . writableRoots ,
1734+ hiddenPaths : policy . hiddenPaths ,
1735+ gitBacked : policy . gitBacked ,
1736+ } ) ,
1737+ } )
1738+ }
1739+
1740+ for ( const [ index , mount ] of ( recipe . inputs ?. mounts ?? [ ] ) . entries ( ) ) {
1741+ if ( ( mount . mode ?? "readwrite" ) !== "readwrite" ) {
1742+ continue
1743+ }
1744+ checks . push ( uncheckedReadwriteInputPolicyCheck ( `inputs.mounts[${ index } ]` , mount . target , mount . metadata ) )
1745+ }
1746+
1747+ for ( const [ index , stagedFile ] of stagedFiles . entries ( ) ) {
1748+ checks . push ( uncheckedReadwriteInputPolicyCheck ( `inputs.stagedFiles[${ index } ]` , stagedFile . target , stagedFile . metadata ) )
1749+ }
1750+
1751+ return {
1752+ schema : "wp-codebox/workspace-policy-artifacts/v1" ,
1753+ passed : checks . every ( ( check ) => check . result . passed ) ,
1754+ checks,
1755+ }
1756+ }
1757+
1758+ function uncheckedReadwriteInputPolicyCheck ( sourceField : string , target : string , metadata ?: Record < string , unknown > ) : RecipeWorkspacePolicyArtifactResult [ "checks" ] [ number ] {
1759+ return {
1760+ workspace : {
1761+ target,
1762+ mode : "readwrite" ,
1763+ metadata : { ...( metadata ?? { } ) , sourceField } ,
1764+ } ,
1765+ result : {
1766+ schema : "wp-codebox/workspace-policy-result/v1" ,
1767+ passed : false ,
1768+ policy_sha256 : createHash ( "sha256" ) . update ( JSON . stringify ( { sourceField, target } ) ) . digest ( "hex" ) ,
1769+ violations : [
1770+ {
1771+ code : "path-outside-workspace" ,
1772+ path : target ,
1773+ message : `${ sourceField } is mounted readwrite but is not a declared workspace policy root. Use inputs.workspaces for policy-checked writable sources or make the mount readonly.` ,
1774+ details : { sourceField, target } ,
1775+ } ,
1776+ ] ,
1777+ } ,
1778+ }
1779+ }
1780+
1781+ async function writeRecipeEvidenceJson ( artifactRoot : string , path : string , value : unknown , kind : string ) : Promise < RecipeArtifactEvidenceFile > {
1782+ const json = `${ JSON . stringify ( value , null , 2 ) } \n`
1783+ await writeFile ( path , json )
1784+ return {
1785+ path : relative ( artifactRoot , path ) ,
1786+ sha256 : createHash ( "sha256" ) . update ( json ) . digest ( "hex" ) ,
1787+ kind ,
1788+ contentType : "application/json" ,
1789+ }
1790+ }
1791+
1792+ async function updateRecipeArtifactEvidenceReferences ( artifacts : ArtifactBundle , evidenceFiles : RecipeArtifactEvidenceFile [ ] ) : Promise < void > {
1793+ const manifest = JSON . parse ( await readFile ( artifacts . manifestPath , "utf8" ) ) as { files ? : Array < Record < string , unknown > > }
1794+ manifest . files = Array . isArray ( manifest . files ) ? manifest . files : [ ]
1795+ for ( const file of evidenceFiles ) {
1796+ const existing = manifest . files . find ( ( entry ) => entry . path === file . path )
1797+ const manifestFile = { path : file . path , kind : file . kind , contentType : file . contentType }
1798+ if ( existing ) {
1799+ Object . assign ( existing , manifestFile )
1800+ } else {
1801+ manifest . files . push ( manifestFile )
1802+ }
1803+ }
1804+ await writeFile ( artifacts . manifestPath , `${ JSON . stringify ( manifest , null , 2 ) } \n` )
1805+
1806+ const evidence = Object . fromEntries ( evidenceFiles . map ( ( file ) => [ file . kind , { path : file . path , sha256 : file . sha256 } ] ) )
1807+ const metadata = JSON . parse ( await readFile ( artifacts . metadataPath , "utf8" ) ) as Record < string , unknown >
1808+ metadata . artifacts = { ...( isRecord ( metadata . artifacts ) ? metadata . artifacts : { } ) , runtimeEvidence : evidence }
1809+ metadata . evidence = { ...( isRecord ( metadata . evidence ) ? metadata . evidence : { } ) , runtimeEvidence : evidence }
1810+ await writeFile ( artifacts . metadataPath , `${ JSON . stringify ( metadata , null , 2 ) } \n` )
1811+
1812+ const review = JSON . parse ( await readFile ( artifacts . reviewPath , "utf8" ) ) as Record < string , unknown >
1813+ review . evidence = { ...( isRecord ( review . evidence ) ? review . evidence : { } ) , runtimeEvidence : evidence }
1814+ await writeFile ( artifacts . reviewPath , `${ JSON . stringify ( review , null , 2 ) } \n` )
1815+ }
1816+
1817+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
1818+ return Boolean ( value ) && typeof value = = = "object" && ! Array . isArray ( value )
1819+ }
1820+
15171821async function validateRecipe ( options : RecipeValidateOptions ) : Promise < RecipeValidateOutput > {
15181822 const recipePath = resolve ( options . recipePath )
15191823 try {
0 commit comments