Skip to content

Commit 270484e

Browse files
authored
Fix recipe-run policy override discoverability
1 parent 18d3c8f commit 270484e

7 files changed

Lines changed: 150 additions & 12 deletions

File tree

docs/recipe-contract.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ npm run wp-codebox -- recipe validate --recipe ./path/to/recipe.json --json
1919
npm run wp-codebox -- recipe-run --recipe ./path/to/recipe.json --dry-run --json
2020
```
2121

22+
`recipe-run` derives the runtime policy from the recipe by default. To run with a
23+
stricter or caller-owned policy, pass `--policy <json|file>` to both validation
24+
and execution:
25+
26+
```bash
27+
npm run wp-codebox -- recipe validate --recipe ./path/to/recipe.json --policy ./runtime-policy.json --json
28+
npm run wp-codebox -- recipe-run --recipe ./path/to/recipe.json --policy ./runtime-policy.json --json
29+
```
30+
31+
The override must include every runtime command required by recipe setup, probes,
32+
and workflow steps. Use `recipe-run --dry-run --json` to inspect the resolved
33+
`plan.policy.commands` list before tightening a policy.
34+
2235
## Minimal Shape
2336

2437
```json

packages/cli/src/commands/recipe-run-types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AgentTerminalResult, ArtifactBundle, ArtifactPackageProvenance, BenchResults, ExecutionResult, PreviewLease, RecipeRunSummary, RuntimeInfo, RuntimeRunRecord, TypedArtifactRef, WorkspaceRecipe, WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
1+
import type { AgentTerminalResult, ArtifactBundle, ArtifactPackageProvenance, BenchResults, ExecutionResult, PreviewLease, RecipeRunSummary, RuntimeInfo, RuntimePolicy, RuntimeRunRecord, TypedArtifactRef, WorkspaceRecipe, WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
22
import type { RecipeDryRunOutput, RecipeDryRunSiteSeed, RecipeDryRunStagedFile } from "../recipe-dry-run.js"
33
import type { AgentSandboxResultSummary, AgentTaskSingleResult, RecipeReplayStatusSummary, SandboxCompletionOutcome } from "../recipe-evidence.js"
44
import type { RecipeExternalServiceBoundaryHostCorrelation } from "../recipe-external-services.js"
@@ -20,13 +20,15 @@ export interface RecipeRunOptions {
2020
previewLeaseId?: string
2121
previewLeaseFile?: string
2222
timeoutMs: number
23+
policy?: RuntimePolicy
2324
json: boolean
2425
summary: boolean
2526
dryRun: boolean
2627
}
2728

2829
export interface RecipeValidateOptions {
2930
recipePath: string
31+
policy?: RuntimePolicy
3032
json: boolean
3133
}
3234

packages/cli/src/commands/recipe-run.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"
33
import { mkdir, readFile, writeFile } from "node:fs/promises"
44
import { createRequire } from "node:module"
55
import { basename, dirname, join, resolve } from "node:path"
6-
import { DEFAULT_WORDPRESS_VERSION, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
6+
import { DEFAULT_WORDPRESS_VERSION, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
77
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
88
import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js"
99
import { captureStdout, printRecipeHumanOutput, printRecipeValidateHumanOutput, serializeError } from "../output.js"
@@ -14,7 +14,7 @@ import { recipeExternalServiceBoundarySummaries } from "../recipe-external-servi
1414
import { resolveRecipeSecretEnv } from "../recipe-secret-env.js"
1515
import type { PreparedRuntimeBackendPackage } from "../recipe-backend-package.js"
1616
import { cleanupRecipePreparedSources, recipeBlueprintWithBootActivePlugins, recipeExtraPlugins, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
17-
import { loadWorkspaceRecipe, recipePolicy, recipeWorkflowSteps, validateWorkspaceRecipe, type RecipeWorkflowPhase } from "../recipe-validation.js"
17+
import { loadWorkspaceRecipe, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeWorkflowPhase } from "../recipe-validation.js"
1818
import { resolveCliRuntimeBackend } from "../runtime-backends.js"
1919
import { previewSpec, releaseRuntime, runtimeMetadata, type RunOutput } from "../runtime-command-wrappers.js"
2020
import { artifactManifestFilesByPath, parseBenchResults, writeBenchmarkArtifactEvidence } from "./recipe-run-benchmark-artifacts.js"
@@ -106,7 +106,10 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
106106
let { runRecord } = context
107107
runRecord = await runRegistry.update(runRecord.runId, { metadata: { provenance: recipeRunProvenance(recipe, recipePath) } })
108108
await artifactPointer.update({ commandStatus: "queued" })
109-
const issues = await validateWorkspaceRecipe(recipe, recipePath)
109+
const issues = [
110+
...await validateWorkspaceRecipe(recipe, recipePath),
111+
...validateRecipeRuntimePolicy(recipe, options.policy),
112+
]
110113
if (issues.length > 0) {
111114
const failure = {
112115
name: "RecipeValidationError",
@@ -127,7 +130,8 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
127130
}
128131

129132
const plan = await planWorkspaceRecipe(recipe, recipeDirectory, { recipePath, artifactsDirectory: configuredArtifactsDirectory }, { defaultWordPressVersion: DEFAULT_WORDPRESS_VERSION, resolveExecutionSpec: recipeExecutionSpec })
130-
const { valid: _policyValid, issues: _policyIssues, ...policy } = plan.policy
133+
const { valid: _policyValid, issues: _policyIssues, ...plannedPolicy } = plan.policy
134+
const policy = options.policy ?? plannedPolicy
131135
const runtimeEnv = {
132136
...distributionRuntimeEnv(recipe),
133137
...normalizeRuntimeEnv(recipe.inputs?.runtimeEnv ?? {}),
@@ -610,7 +614,10 @@ async function validateRecipe(options: RecipeValidateOptions): Promise<RecipeVal
610614
const recipePath = resolve(options.recipePath)
611615
try {
612616
const recipe = await loadWorkspaceRecipe(recipePath)
613-
const issues = await validateWorkspaceRecipe(recipe, recipePath)
617+
const issues = [
618+
...await validateWorkspaceRecipe(recipe, recipePath),
619+
...validateRecipeRuntimePolicy(recipe, options.policy),
620+
]
614621

615622
return {
616623
success: issues.length === 0,
@@ -711,6 +718,9 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions {
711718
case "--timeout":
712719
options.timeoutMs = parseRecipeRunTimeoutMs(value)
713720
break
721+
case "--policy":
722+
options.policy = parseRecipePolicy(value)
723+
break
714724
default:
715725
throw new Error(`Unknown option: ${name}`)
716726
}
@@ -741,6 +751,17 @@ function parseRecipeRunTimeoutMs(value: unknown): number {
741751
return timeoutMs
742752
}
743753

754+
function parseRecipePolicy(value: string): RuntimePolicy {
755+
const raw = value.trim().startsWith("{") ? value : readFileSync(resolve(value), "utf8")
756+
const policy = JSON.parse(raw) as unknown
757+
const result = validateRuntimePolicy(policy)
758+
if (!result.valid) {
759+
throw new Error(`Runtime policy is invalid: ${result.issues.map((issue) => issue.message).join("; ")}`)
760+
}
761+
762+
return policy as RuntimePolicy
763+
}
764+
744765
function parseRecipeValidateOptions(args: string[]): RecipeValidateOptions {
745766
const parsed = parseCommandOptions(args, new Set(["--json"]))
746767
if (parsed.positionals.length > 0) {
@@ -755,6 +776,9 @@ function parseRecipeValidateOptions(args: string[]): RecipeValidateOptions {
755776
case "--recipe":
756777
options.recipePath = value
757778
break
779+
case "--policy":
780+
options.policy = parseRecipePolicy(value)
781+
break
758782
default:
759783
throw new Error(`Unknown option: ${name}`)
760784
}

packages/cli/src/output.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ export function printHelp(): void {
307307
wp-codebox cleanup [--json] [--archive-root <dir>] [--stale-after-seconds <n>]
308308
wp-codebox workspace-policy check --workspace-root <path> --writable-root <path> [options]
309309
wp-codebox recipe build phpunit|bench|template|generic-ability-runtime-run|runtime-package-run --options <path> [--output <path>]
310-
wp-codebox recipe validate --recipe <path> [--json]
310+
wp-codebox recipe validate --recipe <path> [--policy <json|file>] [--json]
311311
wp-codebox bench matrix --matrix <path> [--recipe <path>] [--artifacts <dir>] [--json]
312312
wp-codebox bench summarize (--input <recipe-run.json>|--bundle <dir>) [--json]
313313
wp-codebox bench compare --baseline <recipe-run.json> --candidate <recipe-run.json> [--json]
@@ -427,7 +427,7 @@ Options:
427427
--preview-lease-json <json>
428428
wp-codebox/preview-lease/v1 envelope for public/local URL, expiry, alignment, and handoff metadata.
429429
--timeout <duration> Maximum live recipe-run duration before emitting a structured timeout failure. Defaults to 25m.
430-
--policy <json|file> Runtime policy JSON or path to a JSON file.
430+
--policy <json|file> Runtime policy JSON or path to a JSON file. For recipe-run and recipe validate, this overrides the recipe-derived runtime policy and must include every command required by the recipe setup, probes, and workflow.
431431
--dry-run Validate recipe-run and emit a resolved JSON plan without booting Playground or writing temp workspaces.
432432
--json Emit machine-readable JSON.
433433

packages/cli/src/recipe-dry-run.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { RecipeArtifactsMountConflictError, recipeArtifactsMountConflict } from
66
import { resolveRecipeSecretEnv, type RecipeSecretEnvSummaryEntry } from "./recipe-secret-env.js"
77
import { recipeExternalServiceBoundarySummaries, type RecipeExternalServiceBoundarySummary } from "./recipe-external-services.js"
88
import { composerPackageVendorPath, defaultWorkspaceTarget, installMuPluginsCode, pluginTarget, recipeBlueprintWithBootActivePlugins, recipeExtraPluginFile, recipeExtraPluginSlug, recipeExtraPluginSource, recipeExtraPluginSourceRoot, recipeExtraPluginSourceSubpath, recipeExtraPlugins, recipeMountType, recipeSource, recipeSourceProvenance, resolveRecipeExtraPluginFile, stagedFileMountType, stagedFileProvenance, type RecipeSourceProvenance, type RecipeSourceType, type RecipeStagedFileProvenance } from "./recipe-sources.js"
9-
import { hasExplicitSiteSeedSelectors, loadWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js"
9+
import { hasExplicitSiteSeedSelectors, loadWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js"
1010
import { runtimeOverlayTarget } from "./runtime-overlay-registry.js"
1111

1212
export interface RecipeDryRunOptions {
1313
recipePath: string
1414
artifactsDirectory?: string
15+
policy?: RuntimePolicy
1516
}
1617

1718
export interface RecipeDryRunContext {
@@ -267,7 +268,10 @@ export async function dryRunRecipe(options: RecipeDryRunOptions, context: Recipe
267268
}
268269
}
269270

270-
const issues = await validateWorkspaceRecipe(recipe, recipePath)
271+
const issues = [
272+
...await validateWorkspaceRecipe(recipe, recipePath),
273+
...validateRecipeRuntimePolicy(recipe, options.policy),
274+
]
271275

272276
if (issues.length > 0) {
273277
return {
@@ -315,7 +319,7 @@ export async function dryRunRecipe(options: RecipeDryRunOptions, context: Recipe
315319
}
316320

317321
export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirectory: string, options: RecipePlanOptions, context: RecipePlanContext): Promise<RecipePlan> {
318-
const policy = recipePolicy(recipe)
322+
const policy = options.policy ?? recipePolicy(recipe)
319323
const policyValidation = validateRuntimePolicy(policy)
320324
const workspaces = recipeDryRunWorkspaces(recipe, recipeDirectory)
321325
const extraPlugins = recipeDryRunExtraPlugins(recipe, recipeDirectory)

packages/cli/src/recipe-validation.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFile, stat } from "node:fs/promises"
22
import { dirname, join, resolve } from "node:path"
3-
import { assertFixtureImportDeterministicIdsSupported, assertWorkspaceRecipeJsonSchema, commandArgValue, normalizeRuntimeBackendKind, normalizeRuntimeMountTarget, parseCommandJson, safeArtifactRelativePath, validateBrowserInteractionScript, validateSourcePackage, workspaceRecipeRuntimeCollectedArtifacts, type MountSpec, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeDependencyOverlay, type WorkspaceRecipeDistribution, type WorkspaceRecipeDistributionStartupProbe, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeOverlay, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
3+
import { assertFixtureImportDeterministicIdsSupported, assertWorkspaceRecipeJsonSchema, commandArgValue, normalizeRuntimeBackendKind, normalizeRuntimeMountTarget, parseCommandJson, safeArtifactRelativePath, validateBrowserInteractionScript, validateRuntimePolicy, validateSourcePackage, workspaceRecipeRuntimeCollectedArtifacts, type MountSpec, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeDependencyOverlay, type WorkspaceRecipeDistribution, type WorkspaceRecipeDistributionStartupProbe, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeOverlay, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
44
import { commandValidationDescriptorFor, effectivePolicyCommandsFor, type CommandArgValidationDescriptor } from "@automattic/wp-codebox-core/contracts"
55
import { composerPackageVendorPath, evaluateRecipeSourcePolicy, isComposerPackageName, pluginTarget, recipeExtraPluginSlug, recipeExtraPluginSource, recipeExtraPluginSourceRoot, recipeExtraPluginSourceSubpath, recipeExtraPlugins, recipeSource, resolveRecipeExtraPluginFile } from "./recipe-sources.js"
66
import { loadConfiguredRuntimeOverlayDescriptors, registeredRuntimeOverlayDescriptors, runtimeOverlayDescriptor, runtimeOverlayTarget } from "./runtime-overlay-registry.js"
@@ -479,6 +479,35 @@ export async function validateWorkspaceRecipe(recipe: WorkspaceRecipe, recipePat
479479
return validateWorkspaceRecipeSemantics(recipe, recipePath)
480480
}
481481

482+
export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: RuntimePolicy | undefined): RecipeValidationIssue[] {
483+
if (!policy) {
484+
return []
485+
}
486+
487+
const issues: RecipeValidationIssue[] = []
488+
const policyValidation = validateRuntimePolicy(policy)
489+
for (const issue of policyValidation.issues) {
490+
issues.push({
491+
code: issue.code,
492+
path: `$.policy.${issue.field}`,
493+
message: issue.message,
494+
})
495+
}
496+
497+
const requiredCommands = recipePolicy(recipe).commands
498+
for (const command of requiredCommands) {
499+
if (!policy.commands.includes(command)) {
500+
issues.push({
501+
code: "runtime-policy-missing-command",
502+
path: "$.policy.commands",
503+
message: `Runtime policy commands must include ${command} for this recipe. Run recipe-run --dry-run --json to inspect the resolved plan.policy.commands list.`,
504+
})
505+
}
506+
}
507+
508+
return issues
509+
}
510+
482511
export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, recipePath: string): Promise<RecipeValidationIssue[]> {
483512
const recipeDirectory = dirname(recipePath)
484513
const issues: RecipeValidationIssue[] = []
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import assert from "node:assert/strict"
2+
import { writeFile } from "node:fs/promises"
3+
import { join } from "node:path"
4+
5+
import { runRecipeRunCommand, runRecipeValidateCommand } from "../packages/cli/src/commands/recipe-run.js"
6+
import { printHelp } from "../packages/cli/src/output.js"
7+
import { withTempDir } from "../scripts/test-kit.js"
8+
9+
function captureStdout(callback: () => void): string {
10+
const original = console.log
11+
const lines: string[] = []
12+
console.log = (...args: unknown[]) => lines.push(args.join(" "))
13+
try {
14+
callback()
15+
} finally {
16+
console.log = original
17+
}
18+
return lines.join("\n")
19+
}
20+
21+
await withTempDir("wp-codebox-recipe-run-policy-override-", async (directory) => {
22+
const recipePath = join(directory, "recipe.json")
23+
const restrictivePolicyPath = join(directory, "restrictive-policy.json")
24+
const completePolicyPath = join(directory, "complete-policy.json")
25+
26+
await writeFile(recipePath, JSON.stringify({
27+
schema: "wp-codebox/workspace-recipe/v1",
28+
workflow: {
29+
steps: [
30+
{ command: "wordpress.run-php", args: ["code=echo 'ok';"] },
31+
],
32+
},
33+
}))
34+
await writeFile(restrictivePolicyPath, JSON.stringify({
35+
network: "deny",
36+
filesystem: "readwrite-mounts",
37+
commands: ["inspect-mounted-inputs"],
38+
secrets: "none",
39+
approvals: "never",
40+
}))
41+
await writeFile(completePolicyPath, JSON.stringify({
42+
network: "deny",
43+
filesystem: "readwrite-mounts",
44+
commands: ["inspect-mounted-inputs", "wordpress.run-php"],
45+
secrets: "none",
46+
approvals: "never",
47+
}))
48+
49+
const restrictiveExitCode = await runRecipeValidateCommand(["--recipe", recipePath, "--policy", restrictivePolicyPath, "--json"])
50+
assert.equal(restrictiveExitCode, 1)
51+
52+
const restrictiveDryRunExitCode = await runRecipeRunCommand(["--recipe", recipePath, "--policy", restrictivePolicyPath, "--dry-run", "--json"])
53+
assert.equal(restrictiveDryRunExitCode, 1)
54+
55+
const completeExitCode = await runRecipeValidateCommand(["--recipe", recipePath, "--policy", completePolicyPath, "--json"])
56+
assert.equal(completeExitCode, 0)
57+
58+
const completeDryRunExitCode = await runRecipeRunCommand(["--recipe", recipePath, "--policy", completePolicyPath, "--dry-run", "--json"])
59+
assert.equal(completeDryRunExitCode, 0)
60+
})
61+
62+
const help = captureStdout(printHelp)
63+
assert.match(help, /recipe validate --recipe <path> \[--policy <json\|file>\]/)
64+
assert.match(help, /recipe-run and recipe validate/)
65+
66+
console.log("recipe-run policy override validation ok")

0 commit comments

Comments
 (0)