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
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ if (execution.code === 0 && runtimeRecord.success === true && verificationPassed
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
} catch (error) {
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES) }
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) }
}
}
if (request.success?.requires_pr === true && workspaceApply.status === "no-op" && !publication) {
Expand Down
57 changes: 52 additions & 5 deletions packages/runtime-core/src/runner-workspace-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export interface RunnerWorkspaceIntegritySnapshot {
baseline: RunnerWorkspacePublicationFile[]
}

export interface RunnerWorkspaceIntegrityFailureEvidence {
schema: "wp-codebox/runner-workspace-integrity-failure/v1"
added: string[]
modified: string[]
deleted: string[]
total: number
truncated: boolean
}

/**
* Promotes the canonical sandbox patch artifact into the checked-out workspace.
* Artifact references are treated as locators only after containment and digest
Expand Down Expand Up @@ -157,7 +166,23 @@ export async function runnerWorkspaceIdentity(root: string): Promise<RunnerWorks
export async function verifyRunnerWorkspaceIntegrity(snapshot: RunnerWorkspaceIntegritySnapshot): Promise<void> {
const current = await snapshotWorkspace(snapshot.workspaceRoot)
if (JSON.stringify(current) !== JSON.stringify(snapshot.files)) {
throw new Error("Runner workspace changed after approval; refusing publication.")
const approved = new Map(snapshot.files.map((file) => [file.path, file]))
const actual = new Map(current.map((file) => [file.path, file]))
const added = [...actual.keys()].filter((path) => !approved.has(path)).sort()
const deleted = [...approved.keys()].filter((path) => !actual.has(path)).sort()
const modified = [...approved.keys()].filter((path) => actual.has(path) && JSON.stringify(approved.get(path)) !== JSON.stringify(actual.get(path))).sort()
const changed = [...added, ...modified, ...deleted]
const limit = 100
const error = new Error(`Runner workspace changed after approval; refusing publication. Changed paths: ${changed.slice(0, 10).join(", ")}`) as Error & { evidence?: RunnerWorkspaceIntegrityFailureEvidence }
error.evidence = {
schema: "wp-codebox/runner-workspace-integrity-failure/v1",
added: added.slice(0, limit),
modified: modified.slice(0, limit),
deleted: deleted.slice(0, limit),
total: changed.length,
truncated: changed.length > limit,
}
throw error
}
}

Expand Down Expand Up @@ -235,10 +260,7 @@ function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[]

async function snapshotWorkspace(root: string): Promise<RunnerWorkspacePublicationFile[]> {
const output: RunnerWorkspacePublicationFile[] = []
const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES })
const paths = stdout.split("\0")
.filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/"))
.sort((left, right) => left.localeCompare(right))
const paths = await workspaceSnapshotPaths(root)
for (const path of paths) {
const absolute = resolve(root, path)
if (!pathIsWithinRoot(absolute, root)) throw new Error(`Runner workspace contains a denied path: ${path}`)
Expand All @@ -257,6 +279,31 @@ async function snapshotWorkspace(root: string): Promise<RunnerWorkspacePublicati
return output
}

async function workspaceSnapshotPaths(root: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES })
return stdout.split("\0")
.filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/"))
.sort((left, right) => left.localeCompare(right))
} catch {
const paths: string[] = []
async function visit(directory: string): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if ([".git", ".codebox", "node_modules", "vendor"].includes(entry.name)) continue
const absolute = resolve(directory, entry.name)
const path = relative(root, absolute).replaceAll("\\", "/")
if (entry.isDirectory()) {
await visit(absolute)
} else {
paths.push(path)
}
}
}
await visit(root)
return paths.sort((left, right) => left.localeCompare(right))
}
}

function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], current: RunnerWorkspacePublicationFile[], changed: RunnerWorkspaceChangedFile[]): void {
const before = new Map(baseline.map((file) => [file.path, file]))
const after = new Map(current.map((file) => [file.path, file]))
Expand Down
14 changes: 13 additions & 1 deletion tests/runner-workspace-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,19 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status
const input = await fixture(patch, files)
const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] })
await writeFile(join(input.workspace, "extra.txt"), "unexpected\n")
await assert.rejects(() => verifyRunnerWorkspaceIntegrity(result.integrity!), /changed after approval/)
await assert.rejects(
() => verifyRunnerWorkspaceIntegrity(result.integrity!),
(error: Error & { evidence?: Record<string, any> }) => {
assert.match(error.message, /Changed paths: extra.txt/)
assert.equal(error.evidence?.schema, "wp-codebox/runner-workspace-integrity-failure/v1")
assert.deepEqual(error.evidence?.added, ["extra.txt"])
assert.deepEqual(error.evidence?.modified, [])
assert.deepEqual(error.evidence?.deleted, [])
assert.equal(error.evidence?.total, 1)
assert.equal(error.evidence?.truncated, false)
return true
},
)
}

{
Expand Down
Loading