Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"test:editor-actions-save-integration": "node --import tsx --test tests/editor-actions-save.integration.test.ts",
"test:editor-validate-blocks": "tsx tests/editor-validate-blocks.test.ts",
"test:artifact-result-envelope": "tsx tests/artifact-result-envelope.test.ts",
"test:artifact-redaction-integrity": "tsx tests/artifact-redaction-integrity.test.ts",
"test:async-agent-task-contracts": "tsx tests/async-agent-task-contracts.test.ts",
"test:artifact-reference-dtos": "tsx tests/artifact-reference-dtos.test.ts",
"test:artifact-path-primitives": "tsx tests/artifact-path-primitives.test.ts",
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/commands/recipe-declared-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { DEFAULT_CAPTURED_ARTIFACT_MAX_BYTES, STRUCTURED_ARTIFACT_SCHEMA, TYPED_ARTIFACT_INDEX_SCHEMA, materializeStructuredArtifactFiles, redactJsonValue, workspaceRecipeRuntimeCollectedArtifacts, type ArtifactBundle, type Runtime, type StructuredArtifactPayload, type TypedArtifactRef, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeTypedArtifact } from "@automattic/wp-codebox-core"
import { DEFAULT_CAPTURED_ARTIFACT_MAX_BYTES, STRUCTURED_ARTIFACT_SCHEMA, TYPED_ARTIFACT_INDEX_SCHEMA, materializeStructuredArtifactFiles, redactJsonText, redactJsonValue, workspaceRecipeRuntimeCollectedArtifacts, type ArtifactBundle, type Runtime, type StructuredArtifactPayload, type TypedArtifactRef, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeTypedArtifact } from "@automattic/wp-codebox-core"
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
import { appendRecipeRuntimeEvidenceFiles } from "../recipe-evidence.js"
import { rewriteInputMountPath, type InputMountPathMapping } from "../input-mount-paths.js"
Expand Down Expand Up @@ -128,7 +128,7 @@ export async function materializeTypedRecipeDeclaredArtifacts(artifacts: Artifac
source: artifact.path,
},
})
inputs.push({ artifact, ref, contents, contentType: typedArtifact.contentType })
inputs.push({ artifact, ref, contents: redactTypedArtifactContents(contents, typedArtifact.contentType), contentType: typedArtifact.contentType })
}

if (inputs.length === 0) {
Expand Down Expand Up @@ -159,6 +159,14 @@ export async function materializeTypedRecipeDeclaredArtifacts(artifacts: Artifac
await appendRecipeRuntimeEvidenceFiles(artifacts, files)
}

function redactTypedArtifactContents(contents: Buffer, contentType: string): Buffer {
const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase()
if (mediaType !== "application/json" && !mediaType?.endsWith("+json")) {
return contents
}
return Buffer.from(redactJsonText(contents.toString("utf8"), { profile: "browser_event" }), "utf8")
}

function recipeRunTypedArtifactDeclaration(artifact: RecipeRunDeclaredArtifact): { name: string; type: string; contentType: string; payloadSchema?: string | Record<string, unknown> } | undefined {
const typedArtifact = (artifact as RecipeRunDeclaredArtifact & { typedArtifact?: unknown }).typedArtifact
if (!typedArtifact || typeof typedArtifact !== "object") {
Expand Down
13 changes: 11 additions & 2 deletions packages/runtime-core/src/artifact-capture-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { dirname, isAbsolute, relative, resolve } from "node:path"

import { artifactFileDigest, artifactManifestFile, type ArtifactManifestFile, type ArtifactManifestFileOptions } from "./artifact-manifest.js"
import { resolveArtifactPath, safeArtifactRelativePath } from "./artifact-paths.js"
import { containsSecretLikeValue, redactString } from "./redaction.js"
import { containsSecretLikeValue, redactJsonText, redactString } from "./redaction.js"

export interface ArtifactPartInput {
root: string
Expand Down Expand Up @@ -98,7 +98,11 @@ export async function captureArtifactFile(input: CapturedArtifactFileInput): Pro
return captureSkipped(input, relativePath, "sensitive", "secret-like-value", { originalBytes: contents.byteLength, maxBytes, allowedRoots })
}

const capturedContents = binary ? contents : Buffer.from(input.redact ? input.redact(relativePath, text) : redactString(text), "utf8")
const capturedContents = binary ? contents : Buffer.from(input.redact
? input.redact(relativePath, text)
: isJsonContentType(input.contentType)
? redactJsonText(text, { profile: "browser_event" })
: redactString(text), "utf8")
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, capturedContents)
const manifestFile = artifactManifestFile(relativePath, input.kind, input.contentType ?? (binary ? "application/octet-stream" : "text/plain; charset=utf-8"), artifactFileDigest(capturedContents), {
Expand Down Expand Up @@ -128,6 +132,11 @@ export async function captureArtifactFile(input: CapturedArtifactFileInput): Pro
}
}

function isJsonContentType(contentType: string | undefined): boolean {
const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase()
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"))
}

export function normalizeArtifactPartPath(path: string): string {
return safeArtifactRelativePath(path)
}
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export function redactJsonValue(value: unknown, options: RedactJsonOptions = {},
return value
}

export function redactJsonText(value: string, options: RedactJsonOptions = {}): string {
const redacted = redactJsonValue(JSON.parse(value), options)
return `${JSON.stringify(redacted, null, 2)}\n`
}

export function redactString(value: string, options: RedactStringOptions = {}): string {
return value
.replace(/(^|\r?\n)([ \t]*([A-Za-z0-9_-]+)[ \t]*:[ \t]*)[^\r\n]*/g, (line, prefix: string, assignment: string, key: string) => (
Expand Down
114 changes: 114 additions & 0 deletions tests/artifact-redaction-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import { artifactFileDigest, type ArtifactManifest, type Runtime, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js"
import { verifyArtifactBundle } from "../packages/runtime-core/src/artifact-bundle-verifier.js"
import { collectRecipeDeclaredArtifacts, materializeTypedRecipeDeclaredArtifacts } from "../packages/cli/src/commands/recipe-declared-artifacts.js"
import { collectPlaygroundArtifacts } from "../packages/runtime-playground/src/runtime-artifact-helpers.js"
import type { BrowserArtifact } from "../packages/runtime-playground/src/browser-artifacts.js"

const root = await mkdtemp(join(tmpdir(), "wp-codebox-artifact-redaction-integrity-"))
const artifactRoot = join(root, "artifacts")
const screenshots = [
"files/browser/screenshot-gardner-granted-social-operator.png",
"files/browser/screenshot-ordinary-team-social-denial.png",
]
const secretToken = "sk-abcdefghijklmnopqrstuvwxyz"
interface TypedArtifactIndexFixture {
artifacts: Array<{
name: string
type: string
payload: { oracleIds: string[] }
artifact: { path: string; sha256: string }
}>
}
const typedPayload = {
schema: "fixture/state-transition-ledger/v1",
oracleIds: ["state-loss", "authorization-bypass"],
authorization: "Bearer fixture-authorization-secret",
api_token: secretToken,
}

try {
await mkdir(join(artifactRoot, "files/browser"), { recursive: true })
await Promise.all(screenshots.map((path, index) => writeFile(join(artifactRoot, path), Buffer.from([index + 1]))))
await writeFile(join(artifactRoot, "files/browser/action-summary.json"), `${JSON.stringify({ schema: "wp-codebox/browser-actions/v1", files: { screenshots } }, null, 2)}\n`)

const browserArtifact: BrowserArtifact = {
artifactType: "actions",
requestedUrl: "https://example.test/",
url: "https://example.test/",
preview: { requestedMode: "local", effectiveMode: "local", localOrigin: "https://example.test", effectiveOrigin: "https://example.test", diagnostics: [] },
files: { screenshots, summary: "files/browser/action-summary.json" },
summary: { actions: 2, steps: 2, consoleMessages: 0, errors: 0, finalUrl: "https://example.test/", htmlSnapshot: false, networkEvents: 0, replayability: "partial", screenshot: false, viewport: null },
}
const artifacts = await collectPlaygroundArtifacts({
artifactRoot,
browserProbes: [browserArtifact],
commands: [],
createdAt: "2026-01-01T00:00:00.000Z",
events: [],
info: async () => ({ id: "artifact-redaction-integrity", backend: "wordpress-playground", status: "ready", createdAt: "2026-01-01T00:00:00.000Z", environment: { kind: "wordpress" } }),
mounts: [],
observations: [],
pluginChecks: [],
previewInfo: async () => undefined,
recordArtifactsCollected: () => {},
runtimeId: "artifact-redaction-integrity",
snapshots: [],
spec: { environment: { blueprint: {} } },
themeChecks: [],
})

const recipe = {
schema: "wp-codebox/recipe/v1",
runtime: { kind: "wordpress-playground" },
workflow: { steps: [] },
artifacts: {
verify: { enabled: true, strict: true },
typed: [{ name: "state-transition-ledger", type: "fixture/state-transition-ledger/v1", path: "/tmp/state-transition-ledger.json", contentType: "application/json", parseJson: true }],
},
} as unknown as WorkspaceRecipe
const payloadContents = Buffer.from(JSON.stringify(typedPayload))
const runtime = {
execute: async () => ({
id: "collect-typed-artifact",
command: "wordpress.run-php",
args: [],
exitCode: 0,
stdout: `${JSON.stringify({ exists: true, type: "file", size: payloadContents.byteLength, sha256: artifactFileDigest(payloadContents).value, parsedJson: typedPayload, contentBase64: payloadContents.toString("base64") })}\n`,
stderr: "",
startedAt: "2026-01-01T00:00:00.000Z",
finishedAt: "2026-01-01T00:00:00.000Z",
}),
} as unknown as Runtime
const declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime)
await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts)

const manifest = JSON.parse(await readFile(artifacts.manifestPath, "utf8")) as ArtifactManifest
for (const screenshot of screenshots) {
assert.equal(manifest.files.filter((file) => file.path === screenshot).length, 1)
}
const index = JSON.parse(await readFile(join(artifactRoot, "files/runtime-evidence/typed-artifacts/index.json"), "utf8")) as TypedArtifactIndexFixture
assert.equal(index.artifacts[0].name, "state-transition-ledger")
assert.equal(index.artifacts[0].type, "fixture/state-transition-ledger/v1")
assert.deepEqual(index.artifacts[0].payload.oracleIds, ["state-loss", "authorization-bypass"])
const materializedPath = index.artifacts[0].artifact.path as string
const materializedContents = await readFile(join(artifactRoot, materializedPath), "utf8")
const materializedPayload = JSON.parse(materializedContents)
assert.deepEqual(materializedPayload.oracleIds, ["state-loss", "authorization-bypass"])
assert.equal(materializedPayload.authorization, "[redacted]")
assert.equal(materializedPayload.api_token, "[redacted]")
assert.doesNotMatch(`${JSON.stringify(index)}\n${materializedContents}`, new RegExp(secretToken))
assert.equal(artifactFileDigest(materializedContents).value, index.artifacts[0].artifact.sha256)

const verification = await verifyArtifactBundle(artifactRoot, { strict: true })
assert.equal(verification.valid, true, JSON.stringify(verification.violations, null, 2))
assert.deepEqual(verification.violations, [])
} finally {
await rm(root, { recursive: true, force: true })
}

console.log("artifact redaction integrity ok")
Loading