Skip to content

Commit e961e5f

Browse files
authored
Add recipe runtime evidence artifacts
* Add recipe runtime evidence artifacts * Fail strict policy on unchecked writable inputs
1 parent c57cecf commit e961e5f

6 files changed

Lines changed: 476 additions & 4 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@
5757
"recipe-workflow-phases-smoke": "tsx scripts/recipe-workflow-phases-smoke.ts",
5858
"recipe-site-seed-smoke": "tsx scripts/recipe-site-seed-smoke.ts",
5959
"recipe-staged-files-smoke": "tsx scripts/recipe-staged-files-smoke.ts",
60+
"recipe-runtime-evidence-smoke": "tsx scripts/recipe-runtime-evidence-smoke.ts",
6061
"wp-codebox": "node packages/cli/dist/index.js",
6162
"wordpress-plugin-smoke": "php tests/smoke-wordpress-plugin.php",
62-
"check": "npm run build && npm run discovery-command-smoke && npm run theme-check-normalization-smoke && npm run agent-sandbox-code-smoke && npm run policy-validation-smoke && npm run workspace-policy-smoke && npm run wordpress-plugin-smoke && npm run package-distribution-smoke && npm run artifact-bundle-verifier-smoke && npm run artifact-contract-smoke && npm run external-adapter-contract-smoke && npm run runtime-episode-smoke && npm run core-phpunit-command-smoke && npm run plugin-check-normalization-smoke && npm run recipe-bench-smoke && npm run recipe-dry-run-smoke && npm run recipe-workflow-phases-smoke && npm run recipe-site-seed-smoke && npm run recipe-staged-files-smoke && npm run preview-port-smoke && npm run preview-public-url-canonical-smoke && npm run preview-response-body-smoke && npm run boot-preview-smoke && npm run blueprint-validation-smoke && npm run browser-probe-artifact-smoke && npm run wp-codebox -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json"
63+
"check": "npm run build && npm run discovery-command-smoke && npm run theme-check-normalization-smoke && npm run agent-sandbox-code-smoke && npm run policy-validation-smoke && npm run workspace-policy-smoke && npm run wordpress-plugin-smoke && npm run package-distribution-smoke && npm run artifact-bundle-verifier-smoke && npm run artifact-contract-smoke && npm run external-adapter-contract-smoke && npm run runtime-episode-smoke && npm run core-phpunit-command-smoke && npm run plugin-check-normalization-smoke && npm run recipe-bench-smoke && npm run recipe-dry-run-smoke && npm run recipe-workflow-phases-smoke && npm run recipe-site-seed-smoke && npm run recipe-staged-files-smoke && npm run recipe-runtime-evidence-smoke && npm run preview-port-smoke && npm run preview-public-url-canonical-smoke && npm run preview-response-body-smoke && npm run boot-preview-smoke && npm run blueprint-validation-smoke && npm run browser-probe-artifact-smoke && npm run wp-codebox -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json"
6364
},
6465
"workspaces": [
6566
"packages/*"

packages/cli/src/index.ts

Lines changed: 305 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createWriteStream } from "node:fs"
44
import { createHash } from "node:crypto"
55
import { execFile, spawnSync } from "node:child_process"
66
import { tmpdir } from "node:os"
7-
import { basename, dirname, join, resolve } from "node:path"
7+
import { basename, dirname, join, relative, resolve } from "node:path"
88
import { Readable } from "node:stream"
99
import { pipeline } from "node:stream/promises"
1010
import { 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+
128159
interface 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+
15171821
async function validateRecipe(options: RecipeValidateOptions): Promise<RecipeValidateOutput> {
15181822
const recipePath = resolve(options.recipePath)
15191823
try {

packages/runtime-core/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,24 @@ export interface WorkspaceRecipe {
350350
}
351351
artifacts?: {
352352
directory?: string
353+
verify?: boolean | WorkspaceRecipeArtifactVerifier
354+
workspacePolicy?: boolean | WorkspaceRecipeWorkspacePolicyArtifact
353355
}
354356
}
355357

358+
export interface WorkspaceRecipeArtifactVerifier {
359+
enabled?: boolean
360+
strict?: boolean
361+
}
362+
363+
export interface WorkspaceRecipeWorkspacePolicyArtifact {
364+
enabled?: boolean
365+
strict?: boolean
366+
writableRoots?: string[]
367+
hiddenPaths?: string[]
368+
gitBacked?: boolean
369+
}
370+
356371
export interface WorkspaceRecipeInheritanceRequest {
357372
connectors?: string[]
358373
settings?: string[]
@@ -700,6 +715,8 @@ export interface ArtifactBundle {
700715
patchPath: string
701716
testResultsPath: string
702717
reviewPath: string
718+
artifactVerificationPath?: string
719+
workspacePolicyPath?: string
703720
preview?: ArtifactPreview
704721
contentDigest: string
705722
createdAt: string

0 commit comments

Comments
 (0)