From 9ebfd266e55901ec97a6a26381eed79840b4f583 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Thu, 15 Oct 2026 11:18:36 +0000 Subject: [PATCH 1/4] fix(runtime): exclude generated workspace outputs before capture --- docs/reference/configuration.md | 15 ++ .../runtime/src/workspace-generated-output.ts | 54 +++++ packages/runtime/src/workspace-handoff.ts | 26 ++- .../test/workspace-generated-output.test.ts | 200 ++++++++++++++++++ .../runtime/test/workspace-handoff.test.ts | 7 +- 5 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 packages/runtime/src/workspace-generated-output.ts create mode 100644 packages/runtime/test/workspace-generated-output.test.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index df38c7377..8c532f426 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -200,6 +200,21 @@ The value is fixed at launch, so changing it, or `ULTRAFUZZ_KEEP_WORKSPACES`, for a `resume` has no effect. A deletion that fails is reported as a `TASK_WORKTREE_REMOVAL_FAILED` warning, and the next command retries it. +Workspace patches hand off authored changes, not raw build and coverage output. +In addition to the existing generated roots, capture excludes untracked compiler +JSON in `out/`, `build-info/`, `build-snapshot/`, and contract `.sol/` output +directories at any depth; `solidity-files-cache.json`; `lcov.info`, `*.lcov`, and +`covered..html`/`.txt`; and JSON, binary, or text corpus files under +`recon-corpus/` and `echidna/`. In `.cov-work/` and `.recon/`, `build.json`, +`build-info.json`, `compilation.json`, `compiler-input.json`, `compiler-output.json`, +`standard-input.json`, `standard-output.json`, `recon-coverage.json`, and `corpus/` +output are also excluded. Authored test sources and ordinary configuration beside +those files remain eligible. These generated-output rules do not omit tracked +edits or deletions. Unknown files retain the +existing measured diff-overflow handling. Raw evidence needed downstream belongs +in declared artifacts, which still undergo the publication secret gate; excluding +scratch from a workspace patch never exempts a declared artifact from scanning. + The Forge guard is enabled by default. When Forge is installed, Ultrafuzz resolves the real executable before launch, writes an executable wrapper under the run directory, and places that wrapper ahead of Foundry on worker `PATH`. diff --git a/packages/runtime/src/workspace-generated-output.ts b/packages/runtime/src/workspace-generated-output.ts new file mode 100644 index 000000000..4b4c3fd56 --- /dev/null +++ b/packages/runtime/src/workspace-generated-output.ts @@ -0,0 +1,54 @@ +import { lstatSync } from "node:fs"; +import path from "node:path"; + +const SCRATCH_DIRECTORIES = new Set([".cov-work", ".recon"]); +const BUILD_DIRECTORIES = new Set(["out", "build-info", "build-snapshot"]); +const CORPUS_DIRECTORIES = new Set(["recon-corpus", "echidna"]); +const SCRATCH_BUILD_FILES = new Set([ + "build.json", + "build-info.json", + "compilation.json", + "compiler-input.json", + "compiler-output.json", + "standard-input.json", + "standard-output.json" +]); + +/** + * Additional generated outputs, classified per untracked file rather than by + * dropping a mixed source/scratch directory. The existing root exclusions still + * prune known large trees before Git enumerates them. These rules also work in + * nested layouts and below the diff-overflow threshold (#1308). + * + * Unknown files stay eligible for capture, including authored sources and JSON + * configuration beside the output. Tracked entries never call this classifier. + * Classification must not depend on file contents: replay rebuilds a tree from + * HEAD, where previously handed-off additions still appear as untracked. A new + * compiler-shaped value in an authored JSON file must not make that file vanish. + */ +export function isGeneratedWorkspaceOutput(workspaceRoot: string, relativePath: string): boolean { + const segments = relativePath.split("/"); + const filename = segments.pop() ?? ""; + const scratch = segments.some((segment) => SCRATCH_DIRECTORIES.has(segment)); + const buildJson = + filename.endsWith(".json") && + (segments.some((segment) => BUILD_DIRECTORIES.has(segment) || segment.endsWith(".sol")) || + (scratch && SCRATCH_BUILD_FILES.has(filename))); + const coverage = + filename === "lcov.info" || + filename.endsWith(".lcov") || + /^covered\.[0-9]+\.(?:html|txt)$/u.test(filename) || + (scratch && filename === "recon-coverage.json"); + const corpus = + /\.(?:json|bin|txt)$/u.test(filename) && + (segments.some((segment) => CORPUS_DIRECTORIES.has(segment)) || + (scratch && segments.some((segment) => segment === "corpus"))); + const compilerCache = filename === "solidity-files-cache.json"; + if (!buildJson && !coverage && !corpus && !compilerCache) return false; + + const absolutePath = path.join(workspaceRoot, relativePath); + // Leave symlinks and other non-files to the existing fail-closed path checks. + // A concurrently vanished file must still take the normal staging retry path. + const stat = lstatSync(absolutePath, { throwIfNoEntry: false }); + return stat?.isFile() === true; +} diff --git a/packages/runtime/src/workspace-handoff.ts b/packages/runtime/src/workspace-handoff.ts index 0961e00ca..0d699323a 100644 --- a/packages/runtime/src/workspace-handoff.ts +++ b/packages/runtime/src/workspace-handoff.ts @@ -15,6 +15,7 @@ import { type WorkspacePatchOverflowAttribution, type WorkspacePatchOverflowContributor } from "./git-capture-diagnostics.js"; +import { isGeneratedWorkspaceOutput } from "./workspace-generated-output.js"; const WORKSPACE_PATCH_SCHEMA_VERSION = "ultrafuzz.workspace-patch.v1" as const; const GIT_OBJECT_ID = /^[0-9a-f]{40,64}$/u; @@ -88,9 +89,10 @@ const WORKSPACE_RUNTIME_ROOTS = [".ultrafuzz", ".smithers", "node_modules", "art * index would simply keep the baseline blob and an authored edit would vanish from the patch with no * error and a manifest that still validates. * - * The pathspecs are root-anchored, matching where `recon fuzz .` writes. Agent-chosen variants are not - * guessed here. `captureWorkspacePatch` handles those only after a real diff crosses the patch ceiling, - * then excludes measured untracked contributors at exact-file granularity (issue #368). + * The pathspecs are root-anchored, matching where `recon fuzz .` writes. Per-file classification below + * also excludes recognizable nested build/coverage/corpus output before it can poison a baseline or + * fail the secret gate below the patch ceiling (#1308). Unknown output retains measured overflow + * recovery at exact-file granularity (issue #368). */ const WORKSPACE_GENERATED_ROOTS = ["recon-corpus", "echidna", "magic", "out"] as const; @@ -555,7 +557,8 @@ function stageableWorkspacePaths( ...WORKSPACE_RUNTIME_ROOTS.map((root) => `:(exclude)${root}/**`), // The prompt-owned destinations remain exact. Prefix matching was added as an incident stopgap, // but silently dropped authored paths such as `echidna-config/NewAuthored.sol`. Unknown, nested, - // case-variant and slash-less generated paths are handled by measured overflow recovery instead. + // case-variant and slash-less paths remain eligible unless classified as generated output below + // or excluded by measured overflow recovery. ...WORKSPACE_GENERATED_ROOTS.map((root) => `:(exclude)${root}/**`), ...WORKSPACE_DEPENDENCY_ROOTS.map((root) => `:(exclude)${root}/**`) ], @@ -583,6 +586,21 @@ function stageableWorkspacePaths( // decode into one. Covers a top-level *file* named like a root, which the pathspecs above do not. const top = (separator < 0 ? entry : entry.subarray(0, separator)).toString("utf8"); if (top === ".git" || runtimeRoots.has(top)) return; + if (isUntracked) { + const relativePath = entry.toString("utf8"); + // Never classify a lossy decoded path or bypass a sensitive/path-safety failure. + if (Buffer.from(relativePath).equals(entry)) { + try { + normalizeWorkspacePatchPath(relativePath); + rejectSensitivePath(relativePath); + } catch { + pathspecs.push(entry); + untrackedPaths.set(workspacePathKey(entry), entry); + return; + } + if (isGeneratedWorkspaceOutput(workspaceRoot, relativePath)) return; + } + } const key = workspacePathKey(entry); pathspecs.push(entry); if (isUntracked) untrackedPaths.set(key, entry); diff --git a/packages/runtime/test/workspace-generated-output.test.ts b/packages/runtime/test/workspace-generated-output.test.ts new file mode 100644 index 000000000..9d4c25eb2 --- /dev/null +++ b/packages/runtime/test/workspace-generated-output.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { assertArtifactPublicationsContainNoSecrets } from "@ultrafuzz/artifacts"; + +import { + applyWorkspacePatch, + captureWorkspacePatch, + captureWorkspaceTree, + validateWorkspacePatchCapture +} from "../src/workspace-handoff.js"; +import { temporaryRoot } from "./temporary-root.js"; + +function git(root: string, args: string[]): string { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +function write(root: string, relativePath: string, contents: string): void { + const filename = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, contents); +} + +function fixture(): string { + const root = temporaryRoot("ultrafuzz-generated-output-"); + git(root, ["init", "--quiet", "--initial-branch=main"]); + git(root, ["config", "user.name", "Ultrafuzz test"]); + git(root, ["config", "user.email", "ultrafuzz@example.invalid"]); + write(root, "foundry.toml", "[profile.default]\n"); + git(root, ["add", "foundry.toml"]); + git(root, ["commit", "--quiet", "-m", "fixture"]); + return root; +} + +const compilerDump = JSON.stringify({ input: { language: "Solidity", sources: {} }, output: { contracts: {} } }); + +test("classifies nested build, coverage and corpus output without dropping neighboring authored files", () => { + const root = fixture(); + const downstream = path.join(temporaryRoot("ultrafuzz-generated-downstream-"), "workspace"); + const baseline = captureWorkspaceTree(root); + const generated: Record = { + ".cov-work/out/Fixture.sol/Fixture.json": "{}", + ".cov-work/compilation.json": compilerDump, + ".recon/build.json": compilerDump, + ".recon/compiler-input.json": compilerDump, + ".recon/compiler-output.json": compilerDump, + ".recon/recon-coverage.json": "{}", + ".recon/corpus/seed.json": "[]", + ".recon/covered.123.html": "coverage", + "lcov.info": "SF:src/Fixture.sol\nDA:1,1\nend_of_record\n", + "test/recon/coverage.lcov": "SF:src/Fixture.sol\nDA:1,1\nend_of_record\n", + "test/recon/recon-corpus/seed.bin": "corpus", + "test/recon/echidna/coverage/seed.txt": "corpus", + "custom-output/build-info/build.json": "{}", + "custom-output/build-snapshot/snapshot.json": "{}", + "custom-output/Fixture.sol/Fixture.json": "{}", + ".cov-work/cache/solidity-files-cache.json": "{}" + }; + const authored: Record = { + "test/recon/Handlers.t.sol": "contract Handlers {}\n", + ".cov-work/out/Authored.t.sol": "contract Authored {}\n", + ".recon/Replay.t.sol": "contract Replay {}\n", + ".recon/corpus/Replay.t.sol": "contract ReplayCorpus {}\n", + ".cov-work/config.json": '{"test":"test/recon"}\n', + ".recon/settings.json": compilerDump, + "echidna-config/config.json": '{"testMode":"assertion"}\n', + "test/fixtures/compiler-output.json": compilerDump, + "test/recon/notes.txt": "authored notes\n" + }; + for (const [filename, contents] of Object.entries({ ...generated, ...authored })) write(root, filename, contents); + + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files.map((entry) => entry.path).sort(), Object.keys(authored).sort()); + assert.equal(captured.manifest.excluded_files, undefined, "classification runs before any overflow recovery"); + assert.equal(captureWorkspaceTree(root), captured.manifest.result_tree); + validateWorkspacePatchCapture(root, captured, ["src", "contracts"]); + git(root, ["worktree", "add", "--quiet", "--detach", downstream, "HEAD"]); + applyWorkspacePatch(downstream, captured, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream), captured.manifest.result_tree); + for (const [filename, contents] of Object.entries(authored)) { + assert.equal(fs.readFileSync(path.join(downstream, filename), "utf8"), contents, filename); + } + for (const filename of Object.keys(generated)) { + assert.equal(fs.existsSync(path.join(downstream, filename)), false, filename); + assert.equal(fs.existsSync(path.join(root, filename)), true, "capture must not delete scratch"); + } +}); + +test("keeps a below-limit generated compiler dump out of the fail-closed artifact secret gate", () => { + const root = fixture(); + const baseline = captureWorkspaceTree(root); + const configuredSecret = "fixture-configured-credential-value"; + const fakeCredential = "ghp_AbCdEf1234567890AbCdEf1234567890AbCd"; // gitleaks:allow -- fixed synthetic scanner fixture + const generated = JSON.stringify({ + input: { language: "Solidity", sources: {} }, + output: { metadata: fakeCredential, padding: "x".repeat(16_599_800) } + }); + assert.ok(Buffer.byteLength(generated) < 16 * 1024 * 1024); + assert.equal(generated.includes(configuredSecret), false); + // Prove the synthetic signature is rejected without spending scanner time on + // the repeated padding; the full below-limit dump exercises capture below. + const contaminatedMetadata = Buffer.from(JSON.stringify({ metadata: fakeCredential })); + assert.throws( + () => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", contaminatedMetadata]]), [ + configuredSecret + ]), + /artifact publication contains sensitive data/u + ); + write(root, ".cov-work/compilation.json", generated); + write(root, "test/Harness.t.sol", "contract Harness {}\n"); + + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files, [{ path: "test/Harness.t.sol" }]); + assert.ok(Buffer.byteLength(captured.patch) < 1024); + assert.doesNotThrow(() => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(captured.patch)]]), [ + configuredSecret + ]) + ); + validateWorkspacePatchCapture(root, captured, ["src", "contracts"]); +}); + +test("preserves tracked edits and deletions even when their paths look generated", () => { + const root = fixture(); + write(root, "lcov.info", "original\n"); + write(root, ".recon/build-info/old.json", "{}\n"); + git(root, ["add", "-A"]); + git(root, ["commit", "--quiet", "-m", "tracked fixture data"]); + const baseline = captureWorkspaceTree(root); + const originalIndex = git(root, ["write-tree"]); + write(root, "lcov.info", "edited\n"); + fs.unlinkSync(path.join(root, ".recon/build-info/old.json")); + write(root, ".recon/build-info/new.json", "{}\n"); + + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files.map((entry) => entry.path).sort(), [ + ".recon/build-info/old.json", + "lcov.info" + ]); + assert.match(captured.patch, /deleted file mode/u); + assert.match(captured.patch, /\+edited/u); + assert.equal(git(root, ["write-tree"]), originalIndex, "capture must not modify the real Git index"); + validateWorkspacePatchCapture(root, captured, ["src", "contracts"]); +}); + +test("replays authored scratch JSON across generations even when it acquires compiler output fields", () => { + const root = fixture(); + const downstream = path.join(temporaryRoot("ultrafuzz-generated-chain-"), "workspace"); + const baseline = captureWorkspaceTree(root); + write(root, ".recon/config.json", '{"workers":1}\n'); + const first = captureWorkspacePatch(root, baseline); + write(root, ".recon/config.json", compilerDump); + const second = captureWorkspacePatch(root, first.manifest.result_tree); + assert.deepEqual(second.manifest.files, [{ path: ".recon/config.json" }]); + git(root, ["worktree", "add", "--quiet", "--detach", downstream, "HEAD"]); + applyWorkspacePatch(downstream, first, ["src", "contracts"]); + applyWorkspacePatch(downstream, second, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream), second.manifest.result_tree); + assert.equal(fs.readFileSync(path.join(downstream, ".recon/config.json"), "utf8"), compilerDump); +}); + +test("still rejects configured and credential-shaped secrets in included authored files", () => { + const values = [ + "fixture-configured-credential-value", + "ghp_AbCdEf1234567890AbCdEf1234567890AbCd" // gitleaks:allow -- fixed synthetic scanner fixture + ]; + for (const secret of values) { + for (const filename of [".cov-work/Harness.t.sol", ".recon/config.json", "test/Replay.t.sol"]) { + const root = fixture(); + const baseline = captureWorkspaceTree(root); + write(root, filename, JSON.stringify({ fixture: secret })); + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files, [{ path: filename }]); + assert.throws( + () => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(captured.patch)]]), [ + values[0] ?? "" + ]), + /artifact publication contains sensitive data/u, + filename + ); + } + } +}); + +test("leaves invalid scratch JSON and unsafe generated-looking paths to normal validation", () => { + const root = fixture(); + const baseline = captureWorkspaceTree(root); + write(root, ".recon/config.json", "{incomplete"); + assert.deepEqual(captureWorkspacePatch(root, baseline).manifest.files, [{ path: ".recon/config.json" }]); + fs.symlinkSync("foundry.toml", path.join(root, "lcov.info")); + assert.throws(() => captureWorkspacePatch(root, baseline), /symlink/u); + fs.unlinkSync(path.join(root, "lcov.info")); + write(root, ".recon/.env.json", compilerDump); + assert.throws(() => captureWorkspacePatch(root, baseline), /secret|sensitive|\.env/u); +}); diff --git a/packages/runtime/test/workspace-handoff.test.ts b/packages/runtime/test/workspace-handoff.test.ts index 9e87146d1..e66131209 100644 --- a/packages/runtime/test/workspace-handoff.test.ts +++ b/packages/runtime/test/workspace-handoff.test.ts @@ -1689,7 +1689,7 @@ test("excludes agent-chosen variants of the generated corpus roots", () => { } }); -// The exclusion unit must be the measured FILE, never its first path segment. PR #372 dropped `test/` +// The exclusion unit must be the generated FILE, never its first path segment. PR #372 dropped `test/` // wholesale in this shape, destroying the Solidity suite the node existed to hand off. test("excludes a nested corpus file without dropping authored source beside it", () => { const root = fixture(); @@ -1706,10 +1706,7 @@ test("excludes a nested corpus file without dropping authored source beside it", captured.manifest.files.map((entry) => entry.path), ["test/recon/Handlers.t.sol"] ); - assert.deepEqual( - captured.manifest.excluded_files?.map((entry) => entry.path), - ["test/recon/recon-corpus/seed.bin"] - ); + assert.equal(captured.manifest.excluded_files, undefined, "known nested corpus is classified before diff overflow"); } finally { rmSync(root, { recursive: true, force: true }); } From a2db63b034f5c8f44ad5c379a1e709c0e848c244 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Thu, 15 Oct 2026 11:43:52 +0000 Subject: [PATCH 2/4] fix(runtime): preserve authored JSON and legacy workspace handoffs --- docs/reference/configuration.md | 16 ++-- .../templates/smithers/workflows/workflow.tsx | 22 +++-- .../runtime/src/workspace-generated-output.ts | 15 +++- packages/runtime/src/workspace-handoff.ts | 47 ++++++++--- .../test/fixtures/legacy-workspace-patch.ts | 36 ++++++++ .../test/workspace-generated-output.test.ts | 82 +++++++++++++++++-- .../workspace-preparation-lifecycle.test.ts | 45 +++++++++- 7 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 packages/runtime/test/fixtures/legacy-workspace-patch.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8c532f426..df5ec3256 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -202,16 +202,20 @@ for a `resume` has no effect. A deletion that fails is reported as a Workspace patches hand off authored changes, not raw build and coverage output. In addition to the existing generated roots, capture excludes untracked compiler -JSON in `out/`, `build-info/`, `build-snapshot/`, and contract `.sol/` output -directories at any depth; `solidity-files-cache.json`; `lcov.info`, `*.lcov`, and -`covered..html`/`.txt`; and JSON, binary, or text corpus files under +JSON with matching source/artifact names (`out/.sol/.json`) and +hash-named JSON directly in `build-info/` or `build-snapshot/` inside compiler, +corpus, or recognized scratch directories; `solidity-files-cache.json`; +`lcov.info`, `*.lcov`, and `covered..html`/`.txt`; and JSON, binary, or +text corpus files under `recon-corpus/` and `echidna/`. In `.cov-work/` and `.recon/`, `build.json`, `build-info.json`, `compilation.json`, `compiler-input.json`, `compiler-output.json`, `standard-input.json`, `standard-output.json`, `recon-coverage.json`, and `corpus/` output are also excluded. Authored test sources and ordinary configuration beside -those files remain eligible. These generated-output rules do not omit tracked -edits or deletions. Unknown files retain the -existing measured diff-overflow handling. Raw evidence needed downstream belongs +those files remain eligible, including JSON config directly in nested `out/` +directories and test vectors under source-named fixture directories. These rules +do not omit tracked edits/deletions or paths already authenticated by retained +handoffs and preparation trees. Unknown files retain the existing measured +diff-overflow handling. Raw evidence needed downstream belongs in declared artifacts, which still undergo the publication secret gate; excluding scratch from a workspace patch never exempts a declared artifact from scanning. diff --git a/packages/runtime/src/templates/smithers/workflows/workflow.tsx b/packages/runtime/src/templates/smithers/workflows/workflow.tsx index de6901edc..2e04c6c0c 100644 --- a/packages/runtime/src/templates/smithers/workflows/workflow.tsx +++ b/packages/runtime/src/templates/smithers/workflows/workflow.tsx @@ -3428,6 +3428,16 @@ function materializeWorkspacePatchDependencies( throw new Error(`artifact-contract failure: workspace preparation was modified ${task.attemptId}`); } const captures = authenticatedWorkspacePatchCaptures(task, workspaceRoot); + // A retained v1 dependency can contain a path a newer runtime excludes from + // new captures. Preserve authenticated membership through every tree check, + // including resumed-prefix selection and the next producer's baseline. + const treeAuthority = { + trees: [ + ...captures.map((capture) => capture.manifest.base_tree), + ...(persistedPreparation === undefined ? [] : [persistedPreparation]) + ], + files: captures.flatMap((capture) => capture.manifest.files) + }; // On a RESUME the worktree lives on a durable volume and still holds the previous attempt's state, so it // can already sit at -- or past -- some of these dependencies' outputs. Skip the prefix the worktree // already holds (issue #312). On a fresh run at the pinned baseline nothing normally matches, though a @@ -3436,7 +3446,7 @@ function materializeWorkspacePatchDependencies( const replayFrom = replayWorkspacePatches && captures.length > 0 ? firstDependencyRequiringReplay( - captureWorkspaceTree(workspaceRoot), + captureWorkspaceTree(workspaceRoot, treeAuthority), captures.map((entry) => entry.manifest) ) : 0; @@ -3446,7 +3456,7 @@ function materializeWorkspacePatchDependencies( // exact dependency result tree is absent and the clean base tree is // still present; otherwise the dependency patch is already represented // by the dirty workspace and must not be applied over agent changes. - const currentTree = captureWorkspaceTree(workspaceRoot); + const currentTree = captureWorkspaceTree(workspaceRoot, treeAuthority); if (currentTree !== capture.manifest.base_tree) continue; } applyWorkspacePatch(workspaceRoot, capture, task.productionSourceRoots); @@ -3455,7 +3465,7 @@ function materializeWorkspacePatchDependencies( writeWorkspacePreparationAuthority( task.runRoot, task.attemptId, - captureWorkspaceTree(workspaceRoot), + captureWorkspaceTree(workspaceRoot, treeAuthority), admittedWorkspacePreparationDependencyDigest(task) ); } @@ -3463,7 +3473,7 @@ function materializeWorkspacePatchDependencies( if (persistedPreparation === undefined && evidenceMode === "require") { throw new Error(`artifact-contract failure: workspace preparation is unavailable ${task.attemptId}`); } - const preparationTree = persistedPreparation ?? captureWorkspaceTree(workspaceRoot); + const preparationTree = persistedPreparation ?? captureWorkspaceTree(workspaceRoot, treeAuthority); workspacePatchPreparationTrees.set(task.attemptId, preparationTree); if (persistedPreparation === undefined && evidenceMode === "create") { writeWorkspacePatchPreparation(task, preparationTree); @@ -3481,7 +3491,7 @@ function materializeWorkspacePatchDependencies( if (persistedBaseline === undefined && evidenceMode === "require") { throw new Error(`artifact-contract failure: workspace patch baseline is unavailable ${task.attemptId}`); } - const baselineTree = persistedBaseline ?? captureWorkspaceTree(workspaceRoot); + const baselineTree = persistedBaseline ?? captureWorkspaceTree(workspaceRoot, treeAuthority); workspacePatchBaselineTrees.set(task.attemptId, baselineTree); if (persistedBaseline === undefined && evidenceMode === "create") { writeWorkspacePatchBaseline(task, baselineTree); @@ -3940,7 +3950,7 @@ function replaceSupersededWorkspacePreparation( restoreWorkspaceTreeWithIndexLockRecovery(workspaceRoot, base); removeStaleWorkspaceFiles(workspaceRoot, base); for (const capture of captures) applyWorkspacePatch(workspaceRoot, capture, task.productionSourceRoots); - if (captureWorkspaceTree(workspaceRoot) !== replacementTree) { + if (captureWorkspaceTree(workspaceRoot, { trees: [replacementTree] }) !== replacementTree) { throw new Error( `artifact-contract failure: replacement workspace preparation result changed ${task.attemptId}` ); diff --git a/packages/runtime/src/workspace-generated-output.ts b/packages/runtime/src/workspace-generated-output.ts index 4b4c3fd56..833fdce07 100644 --- a/packages/runtime/src/workspace-generated-output.ts +++ b/packages/runtime/src/workspace-generated-output.ts @@ -2,7 +2,6 @@ import { lstatSync } from "node:fs"; import path from "node:path"; const SCRATCH_DIRECTORIES = new Set([".cov-work", ".recon"]); -const BUILD_DIRECTORIES = new Set(["out", "build-info", "build-snapshot"]); const CORPUS_DIRECTORIES = new Set(["recon-corpus", "echidna"]); const SCRATCH_BUILD_FILES = new Set([ "build.json", @@ -30,10 +29,18 @@ export function isGeneratedWorkspaceOutput(workspaceRoot: string, relativePath: const segments = relativePath.split("/"); const filename = segments.pop() ?? ""; const scratch = segments.some((segment) => SCRATCH_DIRECTORIES.has(segment)); + const parent = segments.at(-1) ?? ""; + // A broad ancestor such as out/ or Fixture.sol/ also holds authored config + // and test vectors. Require matching source/artifact names in a compiler layout + // or a hash-named build snapshot, not merely a familiar directory name. + const contractArtifact = + segments.at(-2) === "out" && parent.endsWith(".sol") && filename === `${parent.slice(0, -4)}.json`; + const buildSnapshot = + (parent === "build-info" || parent === "build-snapshot") && + /^[0-9a-f]{8,64}\.json$/u.test(filename) && + (scratch || segments.slice(0, -1).some((segment) => segment === "out" || CORPUS_DIRECTORIES.has(segment))); const buildJson = - filename.endsWith(".json") && - (segments.some((segment) => BUILD_DIRECTORIES.has(segment) || segment.endsWith(".sol")) || - (scratch && SCRATCH_BUILD_FILES.has(filename))); + filename.endsWith(".json") && (contractArtifact || buildSnapshot || (scratch && SCRATCH_BUILD_FILES.has(filename))); const coverage = filename === "lcov.info" || filename.endsWith(".lcov") || diff --git a/packages/runtime/src/workspace-handoff.ts b/packages/runtime/src/workspace-handoff.ts index 0d699323a..e08cefa76 100644 --- a/packages/runtime/src/workspace-handoff.ts +++ b/packages/runtime/src/workspace-handoff.ts @@ -158,11 +158,30 @@ export interface WorkspacePatchGitFacts { patchSha256: string; } -/** Return the tracked tree represented by the complete current worktree. */ -export function captureWorkspaceTree(workspaceRoot: string): string { +interface WorkspaceTreeCaptureAuthority { + /** Trees authenticated by preparation or dependency handoff authority. */ + trees?: readonly string[]; + /** Changed paths authenticated by a dependency handoff, including deletions. */ + files?: readonly WorkspacePatchFile[]; +} + +/** + * Return the current worktree tree. Paths already authenticated by a handoff + * remain eligible even if a newer runtime now classifies their layout as scratch. + * Without authority this captures only candidates for a new handoff; replay and + * preparation must supply their authenticated baseline/path membership. + */ +export function captureWorkspaceTree(workspaceRoot: string, authority: WorkspaceTreeCaptureAuthority = {}): string { + const preservedPaths = new Set((authority.files ?? []).map((entry) => workspacePathKey(Buffer.from(entry.path)))); + for (const tree of new Set(authority.trees ?? [])) { + assertObjectId(tree, "workspace tree capture authority"); + for (const entry of splitNulBuffer(runGitBuffer(workspaceRoot, ["ls-tree", "-r", "--name-only", "-z", tree]))) { + preservedPaths.add(workspacePathKey(entry)); + } + } return withTemporaryIndex(workspaceRoot, (index) => { runGit(workspaceRoot, ["read-tree", "HEAD"], index); - stageWorkspaceTree(workspaceRoot, index); + stageWorkspaceTree(workspaceRoot, index, preservedPaths); return runGit(workspaceRoot, ["write-tree"], index).trim(); }); } @@ -432,7 +451,8 @@ export function applyWorkspacePatch( expectedProductionSourceRoots: readonly string[] ): void { validateWorkspacePatchCapture(workspaceRoot, capture, expectedProductionSourceRoots); - const currentTree = captureWorkspaceTree(workspaceRoot); + const authority = { trees: [capture.manifest.base_tree], files: capture.manifest.files }; + const currentTree = captureWorkspaceTree(workspaceRoot, authority); if (currentTree === capture.manifest.result_tree) return; if (currentTree !== capture.manifest.base_tree) { throw new Error(`workspace patch base tree mismatch: expected ${capture.manifest.base_tree}, got ${currentTree}`); @@ -446,7 +466,7 @@ export function applyWorkspacePatch( } runGit(workspaceRoot, ["apply", "--check", "--binary", "--whitespace=nowarn", "-"], undefined, capture.patch); runGit(workspaceRoot, ["apply", "--binary", "--whitespace=nowarn", "-"], undefined, capture.patch); - const appliedTree = captureWorkspaceTree(workspaceRoot); + const appliedTree = captureWorkspaceTree(workspaceRoot, authority); if (appliedTree !== capture.manifest.result_tree) { throw new Error( `workspace patch result tree mismatch: expected ${capture.manifest.result_tree}, got ${appliedTree}` @@ -500,13 +520,17 @@ function parseChangedPaths(raw: string): WorkspacePatchFile[] { * in the index and nothing here ever names a root path, so their entries are `treeish` by * construction. */ -function stageWorkspaceTree(workspaceRoot: string, index: string): { untrackedPaths: ReadonlyMap } { +function stageWorkspaceTree( + workspaceRoot: string, + index: string, + preservedPaths: ReadonlySet = new Set() +): { untrackedPaths: ReadonlyMap } { // A listed path can vanish before it is staged — agent subprocesses are still running during // capture — and `git add --pathspec-from-file` fails the whole invocation when a name matches // nothing (`--ignore-errors` does not suppress it). Re-list and retry rather than aborting the // run, which is the failure this whole function exists to avoid. for (let attempt = 0; ; attempt += 1) { - const stageable = stageableWorkspacePaths(workspaceRoot, index); + const stageable = stageableWorkspacePaths(workspaceRoot, index, preservedPaths); if (stageable.pathspecs.length === 0) return { untrackedPaths: stageable.untrackedPaths }; try { runGit( @@ -530,7 +554,8 @@ const MAX_MEASURED_EXCLUSION_CANDIDATES = 4096; /** Every path git would stage, minus the runtime roots and any directory entry. */ function stageableWorkspacePaths( workspaceRoot: string, - index: string + index: string, + preservedPaths: ReadonlySet ): { pathspecs: Buffer[]; untrackedPaths: ReadonlyMap } { // Two listings rather than one, so the generated-root exclusions apply to UNTRACKED paths only. // Excluding a tracked path here would be silent data loss: staging runs after `read-tree `, @@ -586,6 +611,7 @@ function stageableWorkspacePaths( // decode into one. Covers a top-level *file* named like a root, which the pathspecs above do not. const top = (separator < 0 ? entry : entry.subarray(0, separator)).toString("utf8"); if (top === ".git" || runtimeRoots.has(top)) return; + const key = workspacePathKey(entry); if (isUntracked) { const relativePath = entry.toString("utf8"); // Never classify a lossy decoded path or bypass a sensitive/path-safety failure. @@ -595,13 +621,12 @@ function stageableWorkspacePaths( rejectSensitivePath(relativePath); } catch { pathspecs.push(entry); - untrackedPaths.set(workspacePathKey(entry), entry); + untrackedPaths.set(key, entry); return; } - if (isGeneratedWorkspaceOutput(workspaceRoot, relativePath)) return; + if (!preservedPaths.has(key) && isGeneratedWorkspaceOutput(workspaceRoot, relativePath)) return; } } - const key = workspacePathKey(entry); pathspecs.push(entry); if (isUntracked) untrackedPaths.set(key, entry); }; diff --git a/packages/runtime/test/fixtures/legacy-workspace-patch.ts b/packages/runtime/test/fixtures/legacy-workspace-patch.ts new file mode 100644 index 000000000..e60ba9c84 --- /dev/null +++ b/packages/runtime/test/fixtures/legacy-workspace-patch.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +import type { WorkspacePatchCapture } from "../../src/workspace-handoff.js"; +import { temporaryRoot } from "../temporary-root.js"; + +/** Build a valid v1 fixture with Git, without the new generated-file classifier. */ +export function captureLegacyWorkspacePatch(root: string, baseline: string): WorkspacePatchCapture { + const index = path.join(temporaryRoot("ultrafuzz-legacy-index-"), "index"); + const indexedGit = (args: string[]): string => + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + env: { ...process.env, GIT_INDEX_FILE: index } + }); + indexedGit(["read-tree", baseline]); + indexedGit(["add", "-A"]); + const patch = indexedGit(["diff", "--cached", "--binary", "--no-renames", baseline]); + return { + patch, + manifest: { + schema_version: "ultrafuzz.workspace-patch.v1", + base_commit: indexedGit(["rev-parse", "HEAD"]).trim(), + base_tree: baseline, + result_tree: indexedGit(["write-tree"]).trim(), + patch_sha256: createHash("sha256").update(patch).digest("hex"), + files: indexedGit(["diff", "--cached", "--name-only", "-z", "--no-renames", baseline]) + .split("\0") + .filter(Boolean) + .sort() + .map((filename) => ({ path: filename })), + source_snapshot: { status: "preserved", protected_roots: ["src", "contracts"] } + } + }; +} diff --git a/packages/runtime/test/workspace-generated-output.test.ts b/packages/runtime/test/workspace-generated-output.test.ts index 9d4c25eb2..a6767a978 100644 --- a/packages/runtime/test/workspace-generated-output.test.ts +++ b/packages/runtime/test/workspace-generated-output.test.ts @@ -12,6 +12,7 @@ import { captureWorkspaceTree, validateWorkspacePatchCapture } from "../src/workspace-handoff.js"; +import { captureLegacyWorkspacePatch } from "./fixtures/legacy-workspace-patch.js"; import { temporaryRoot } from "./temporary-root.js"; function git(root: string, args: string[]): string { @@ -54,9 +55,9 @@ test("classifies nested build, coverage and corpus output without dropping neigh "test/recon/coverage.lcov": "SF:src/Fixture.sol\nDA:1,1\nend_of_record\n", "test/recon/recon-corpus/seed.bin": "corpus", "test/recon/echidna/coverage/seed.txt": "corpus", - "custom-output/build-info/build.json": "{}", - "custom-output/build-snapshot/snapshot.json": "{}", - "custom-output/Fixture.sol/Fixture.json": "{}", + "custom-output/out/build-info/0123456789abcdef.json": "{}", + ".recon/build-snapshot/0123456789abcdef.json": "{}", + "custom-output/out/Fixture.sol/Fixture.json": "{}", ".cov-work/cache/solidity-files-cache.json": "{}" }; const authored: Record = { @@ -68,7 +69,16 @@ test("classifies nested build, coverage and corpus output without dropping neigh ".recon/settings.json": compilerDump, "echidna-config/config.json": '{"testMode":"assertion"}\n', "test/fixtures/compiler-output.json": compilerDump, - "test/recon/notes.txt": "authored notes\n" + "test/recon/notes.txt": "authored notes\n", + "test/recon/out/config.json": '{"workers":1}\n', + "test/recon/out/Harness.t.sol": "contract Harness {}\n", + "test/fixtures/Fixture.sol/cases.json": '{"cases":[1,2,3]}\n', + "test/fixtures/Fixture.sol/README.txt": "authored fixture description\n", + "test/fixtures/build-info/cases.json": '{"cases":[1,2,3]}\n', + "test/fixtures/build-snapshot/config.json": '{"workers":1}\n', + "test/fixtures/build-info/0123456789abcdef.json": '{"cases":[1,2,3]}\n', + "test/fixtures/out/Fixture.sol/cases.json": '{"cases":[1,2,3]}\n', + "custom-output/Fixture.sol/Fixture.json": compilerDump }; for (const [filename, contents] of Object.entries({ ...generated, ...authored })) write(root, filename, contents); @@ -127,18 +137,18 @@ test("keeps a below-limit generated compiler dump out of the fail-closed artifac test("preserves tracked edits and deletions even when their paths look generated", () => { const root = fixture(); write(root, "lcov.info", "original\n"); - write(root, ".recon/build-info/old.json", "{}\n"); + write(root, ".recon/build-info/0123456789abcdef.json", "{}\n"); git(root, ["add", "-A"]); git(root, ["commit", "--quiet", "-m", "tracked fixture data"]); const baseline = captureWorkspaceTree(root); const originalIndex = git(root, ["write-tree"]); write(root, "lcov.info", "edited\n"); - fs.unlinkSync(path.join(root, ".recon/build-info/old.json")); - write(root, ".recon/build-info/new.json", "{}\n"); + fs.unlinkSync(path.join(root, ".recon/build-info/0123456789abcdef.json")); + write(root, ".recon/build-info/fedcba9876543210.json", "{}\n"); const captured = captureWorkspacePatch(root, baseline); assert.deepEqual(captured.manifest.files.map((entry) => entry.path).sort(), [ - ".recon/build-info/old.json", + ".recon/build-info/0123456789abcdef.json", "lcov.info" ]); assert.match(captured.patch, /deleted file mode/u); @@ -163,13 +173,67 @@ test("replays authored scratch JSON across generations even when it acquires com assert.equal(fs.readFileSync(path.join(downstream, ".recon/config.json"), "utf8"), compilerDump); }); +test("replays retained v1 generated entries and preserves their authority across updates and deletions", () => { + const root = fixture(); + const downstream = path.join(temporaryRoot("ultrafuzz-legacy-downstream-"), "workspace"); + const baseline = captureWorkspaceTree(root); + git(root, ["worktree", "add", "--quiet", "--detach", downstream, "HEAD"]); + write(root, ".recon/build.json", "{}\n"); + write(root, "lcov.info", "legacy coverage\n"); + write(root, "test/Harness.t.sol", "contract Harness {}\n"); + const first = captureLegacyWorkspacePatch(root, baseline); + validateWorkspacePatchCapture(downstream, first, ["src", "contracts"]); + applyWorkspacePatch(downstream, first, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream, { trees: [first.manifest.result_tree] }), first.manifest.result_tree); + applyWorkspacePatch(downstream, first, ["src", "contracts"]); + + // New scratch is not grandfathered merely because a retained handoff exists. + write(downstream, ".cov-work/compiler-output.json", "new scratch\n"); + assert.equal(captureWorkspaceTree(downstream, { trees: [first.manifest.result_tree] }), first.manifest.result_tree); + write(downstream, ".recon/build.json", "modified legacy data\n"); + assert.throws(() => applyWorkspacePatch(downstream, first, ["src", "contracts"]), /base tree mismatch/u); + write(downstream, ".recon/build.json", "{}\n"); + + // This new-runtime patch inherits the legacy generated entries through its + // base tree, without naming them in its own changed-file manifest. + write(root, "test/Harness.t.sol", "contract Harness { uint256 x; }\n"); + const second = captureWorkspacePatch(root, first.manifest.result_tree); + assert.deepEqual(second.manifest.files, [{ path: "test/Harness.t.sol" }]); + applyWorkspacePatch(downstream, second, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream, { trees: [second.manifest.result_tree] }), second.manifest.result_tree); + const configuredSecret = "fixture-configured-credential-value"; + write(root, ".recon/build.json", JSON.stringify({ credential: configuredSecret })); + const contaminated = captureWorkspacePatch(root, second.manifest.result_tree); + assert.deepEqual(contaminated.manifest.files, [{ path: ".recon/build.json" }]); + assert.throws( + () => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(contaminated.patch)]]), [ + configuredSecret + ]), + /artifact publication contains sensitive data/u + ); + fs.unlinkSync(path.join(root, ".recon/build.json")); + const third = captureWorkspacePatch(root, second.manifest.result_tree); + assert.deepEqual(third.manifest.files, [{ path: ".recon/build.json" }]); + applyWorkspacePatch(downstream, third, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream, { trees: [third.manifest.result_tree] }), third.manifest.result_tree); + assert.equal(fs.existsSync(path.join(downstream, ".recon/build.json")), false); + assert.equal(fs.readFileSync(path.join(downstream, "lcov.info"), "utf8"), "legacy coverage\n"); +}); + test("still rejects configured and credential-shaped secrets in included authored files", () => { const values = [ "fixture-configured-credential-value", "ghp_AbCdEf1234567890AbCdEf1234567890AbCd" // gitleaks:allow -- fixed synthetic scanner fixture ]; for (const secret of values) { - for (const filename of [".cov-work/Harness.t.sol", ".recon/config.json", "test/Replay.t.sol"]) { + for (const filename of [ + ".cov-work/Harness.t.sol", + ".recon/config.json", + "test/Replay.t.sol", + "test/recon/out/config.json", + "test/fixtures/Fixture.sol/cases.json" + ]) { const root = fixture(); const baseline = captureWorkspaceTree(root); write(root, filename, JSON.stringify({ fixture: secret })); diff --git a/packages/runtime/test/workspace-preparation-lifecycle.test.ts b/packages/runtime/test/workspace-preparation-lifecycle.test.ts index d290b12db..b773a52fc 100644 --- a/packages/runtime/test/workspace-preparation-lifecycle.test.ts +++ b/packages/runtime/test/workspace-preparation-lifecycle.test.ts @@ -10,6 +10,7 @@ import { z } from "zod/v4"; import * as artifacts from "@ultrafuzz/artifacts"; import * as runtime from "../src/index.js"; +import { captureLegacyWorkspacePatch } from "./fixtures/legacy-workspace-patch.js"; import { temporaryRoot } from "./temporary-root.js"; function templatePath(): string { @@ -208,7 +209,8 @@ function lifecycleFixture( second: string; root: string; publishSecond: () => void; - }) => void + }) => void, + options: { legacyGeneratedFile?: boolean } = {} ): void { const root = fs.realpathSync(temporaryRoot("uf-preparation-lifecycle-")); const previousCwd = process.cwd(); @@ -230,11 +232,18 @@ function lifecycleFixture( fs.mkdirSync(path.join(workspace, "test")); const source = path.join(workspace, "test/Setup.t.sol"); fs.writeFileSync(source, "contract Setup { uint constant GENERATION = 1; }\n"); - const first = runtime.captureWorkspacePatch(workspace, base, ["src", "contracts"]); + const legacyGenerated = path.join(workspace, ".recon/build.json"); + if (options.legacyGeneratedFile) { + fs.mkdirSync(path.dirname(legacyGenerated), { recursive: true }); + fs.writeFileSync(legacyGenerated, "{}\n"); + } + const capture = options.legacyGeneratedFile ? captureLegacyWorkspacePatch : runtime.captureWorkspacePatch; + const first = capture(workspace, base); fs.writeFileSync(source, "contract Setup { uint constant GENERATION = 2; }\n"); - const second = runtime.captureWorkspacePatch(workspace, base, ["src", "contracts"]); + const second = capture(workspace, base); runtime.restoreWorkspaceTreeWithIndexLockRecovery(workspace, base); fs.rmSync(source, { force: true }); + if (options.legacyGeneratedFile) fs.rmSync(legacyGenerated); const publish = (capture: typeof first) => { fs.writeFileSync(path.join(dependency, "workspace.patch"), capture.patch); fs.writeFileSync(path.join(dependency, "workspace-patch.json"), JSON.stringify(capture.manifest)); @@ -287,6 +296,36 @@ function lifecycleFixture( } } +test("retained v1 generated paths survive preparation, resume and dependency replacement", () => + lifecycleFixture( + (fixture) => { + const initial = preparationHarness(fixture.tasks); + const workspace = fixture.task.workspacePath; + initial.prepare(fixture.task); + assert.equal(initial.baseline(fixture.task), fixture.first); + assert.equal(initial.preparation(fixture.task), fixture.first); + assert.equal(runtime.captureWorkspaceTree(workspace, { trees: [fixture.first] }), fixture.first); + initial.prepare(fixture.task); + preparationHarness(fixture.tasks).prepare(fixture.task); + + fixture.publishSecond(); + const reopened = preparationHarness(fixture.tasks); + reopened.prepare(fixture.task); + assert.equal(reopened.baseline(fixture.task), fixture.second); + assert.equal(reopened.preparation(fixture.task), fixture.second); + assert.equal(runtime.captureWorkspaceTree(workspace, { trees: [fixture.second] }), fixture.second); + assert.equal(fs.readFileSync(path.join(workspace, ".recon/build.json"), "utf8"), "{}\n"); + fs.mkdirSync(path.join(workspace, ".cov-work")); + fs.writeFileSync(path.join(workspace, ".cov-work/compiler-output.json"), "new scratch\n"); + reopened.prepare(fixture.task, { replayWorkspacePatches: false, evidenceMode: "require" }); + assert.equal(reopened.baseline(fixture.task), fixture.second); + const captured = runtime.captureWorkspacePatch(workspace, fixture.second); + assert.deepEqual(captured.manifest.files, []); + assert.equal(captured.manifest.result_tree, fixture.second); + }, + { legacyGeneratedFile: true } + )); + test("#1081 complete preparation regenerates every pre-agent store after a dependency replacement", () => lifecycleFixture((fixture) => { const initial = preparationHarness(fixture.tasks); From bfa48986ad2e5f2c1a0d492a27d231c8cf62fca7 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Thu, 15 Oct 2026 12:03:02 +0000 Subject: [PATCH 3/4] fix(runtime): recognize real Foundry compiler artifact layouts --- docs/reference/configuration.md | 7 +- .../runtime/src/workspace-generated-output.ts | 111 ++++++++--- .../runtime/test/fixtures/foundry-artifact.ts | 86 ++++++++ .../test/workspace-generated-output.test.ts | 184 +++++++++++++++++- 4 files changed, 358 insertions(+), 30 deletions(-) create mode 100644 packages/runtime/test/fixtures/foundry-artifact.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index df5ec3256..3675add0c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -202,7 +202,9 @@ for a `resume` has no effect. A deletion that fails is reported as a Workspace patches hand off authored changes, not raw build and coverage output. In addition to the existing generated roots, capture excludes untracked compiler -JSON with matching source/artifact names (`out/.sol/.json`) and +JSON in `out/.sol/.json` with ABI, bytecode, and Solidity compiler +metadata whose compilation target matches that source and type (including test, +script, and multiple-type source files), and hash-named JSON directly in `build-info/` or `build-snapshot/` inside compiler, corpus, or recognized scratch directories; `solidity-files-cache.json`; `lcov.info`, `*.lcov`, and `covered..html`/`.txt`; and JSON, binary, or @@ -214,7 +216,8 @@ output are also excluded. Authored test sources and ordinary configuration besid those files remain eligible, including JSON config directly in nested `out/` directories and test vectors under source-named fixture directories. These rules do not omit tracked edits/deletions or paths already authenticated by retained -handoffs and preparation trees. Unknown files retain the existing measured +handoffs and preparation trees. Compiler inspection is bounded to 16 MiB per +file; oversized, malformed, and otherwise unknown files retain the existing measured diff-overflow handling. Raw evidence needed downstream belongs in declared artifacts, which still undergo the publication secret gate; excluding scratch from a workspace patch never exempts a declared artifact from scanning. diff --git a/packages/runtime/src/workspace-generated-output.ts b/packages/runtime/src/workspace-generated-output.ts index 833fdce07..42dcb38dc 100644 --- a/packages/runtime/src/workspace-generated-output.ts +++ b/packages/runtime/src/workspace-generated-output.ts @@ -1,6 +1,8 @@ import { lstatSync } from "node:fs"; import path from "node:path"; +import { isRecord, parseStrictJsonBytes, readRegularFileSnapshot } from "@ultrafuzz/artifacts"; + const SCRATCH_DIRECTORIES = new Set([".cov-work", ".recon"]); const CORPUS_DIRECTORIES = new Set(["recon-corpus", "echidna"]); const SCRATCH_BUILD_FILES = new Set([ @@ -12,6 +14,9 @@ const SCRATCH_BUILD_FILES = new Set([ "standard-input.json", "standard-output.json" ]); +// Do not read arbitrarily large payloads just to classify a path. Unknown or +// oversized output stays eligible for capture and measured overflow recovery. +const MAX_COMPILER_ARTIFACT_BYTES = 16 * 1024 * 1024; /** * Additional generated outputs, classified per untracked file rather than by @@ -21,41 +26,99 @@ const SCRATCH_BUILD_FILES = new Set([ * * Unknown files stay eligible for capture, including authored sources and JSON * configuration beside the output. Tracked entries never call this classifier. - * Classification must not depend on file contents: replay rebuilds a tree from - * HEAD, where previously handed-off additions still appear as untracked. A new - * compiler-shaped value in an authored JSON file must not make that file vanish. + * Previously handed-off paths are also preserved by explicit tree/manifest + * authority: an authored JSON file acquiring compiler fields cannot vanish from + * its next patch or from replay's reconstruction of an authenticated tree. */ export function isGeneratedWorkspaceOutput(workspaceRoot: string, relativePath: string): boolean { const segments = relativePath.split("/"); const filename = segments.pop() ?? ""; const scratch = segments.some((segment) => SCRATCH_DIRECTORIES.has(segment)); + const sourceFilename = segments.at(-1) ?? ""; + const compilerLayout = segments.at(-2) === "out" && sourceFilename.endsWith(".sol") && filename.endsWith(".json"); + const namedOutput = isNamedOutput(segments, filename, scratch); + if (!namedOutput && !compilerLayout) return false; + + const absolutePath = path.join(workspaceRoot, relativePath); + // Leave symlinks and other non-files to the existing fail-closed path checks. + // A concurrently vanished file must still take the normal staging retry path. + const stat = lstatSync(absolutePath, { throwIfNoEntry: false }); + if (stat?.isFile() !== true) return false; + return namedOutput || isCompilerArtifact(absolutePath, sourceFilename, filename); +} + +function isNamedOutput(segments: readonly string[], filename: string, scratch: boolean): boolean { + return ( + (scratch && SCRATCH_BUILD_FILES.has(filename)) || + isBuildSnapshot(segments, filename, scratch) || + isCoverageOutput(filename, scratch) || + isCorpusOutput(segments, filename, scratch) || + filename === "solidity-files-cache.json" + ); +} + +function isBuildSnapshot(segments: readonly string[], filename: string, scratch: boolean): boolean { const parent = segments.at(-1) ?? ""; - // A broad ancestor such as out/ or Fixture.sol/ also holds authored config - // and test vectors. Require matching source/artifact names in a compiler layout - // or a hash-named build snapshot, not merely a familiar directory name. - const contractArtifact = - segments.at(-2) === "out" && parent.endsWith(".sol") && filename === `${parent.slice(0, -4)}.json`; - const buildSnapshot = + return ( (parent === "build-info" || parent === "build-snapshot") && /^[0-9a-f]{8,64}\.json$/u.test(filename) && - (scratch || segments.slice(0, -1).some((segment) => segment === "out" || CORPUS_DIRECTORIES.has(segment))); - const buildJson = - filename.endsWith(".json") && (contractArtifact || buildSnapshot || (scratch && SCRATCH_BUILD_FILES.has(filename))); - const coverage = + (scratch || segments.slice(0, -1).some((segment) => segment === "out" || CORPUS_DIRECTORIES.has(segment))) + ); +} + +function isCoverageOutput(filename: string, scratch: boolean): boolean { + return ( filename === "lcov.info" || filename.endsWith(".lcov") || /^covered\.[0-9]+\.(?:html|txt)$/u.test(filename) || - (scratch && filename === "recon-coverage.json"); - const corpus = + (scratch && filename === "recon-coverage.json") + ); +} + +function isCorpusOutput(segments: readonly string[], filename: string, scratch: boolean): boolean { + return ( /\.(?:json|bin|txt)$/u.test(filename) && - (segments.some((segment) => CORPUS_DIRECTORIES.has(segment)) || - (scratch && segments.some((segment) => segment === "corpus"))); - const compilerCache = filename === "solidity-files-cache.json"; - if (!buildJson && !coverage && !corpus && !compilerCache) return false; + (segments.some((segment) => CORPUS_DIRECTORIES.has(segment)) || (scratch && segments.includes("corpus"))) + ); +} - const absolutePath = path.join(workspaceRoot, relativePath); - // Leave symlinks and other non-files to the existing fail-closed path checks. - // A concurrently vanished file must still take the normal staging retry path. - const stat = lstatSync(absolutePath, { throwIfNoEntry: false }); - return stat?.isFile() === true; +function isCompilerArtifact(absolutePath: string, sourceFilename: string, filename: string): boolean { + // Foundry names JSON after the compiled type, not its source: Harness.t.sol + // produces Harness.json, and one source can produce several differently named + // types. Positive compiler structure plus its matching compilation target + // distinguishes these from authored config/cases in the same output layout. + try { + const artifact = parseStrictJsonBytes(readRegularFileSnapshot(absolutePath, MAX_COMPILER_ARTIFACT_BYTES), { + maxBytes: MAX_COMPILER_ARTIFACT_BYTES + }); + return ( + isRecord(artifact) && + Array.isArray(artifact.abi) && + isBytecode(artifact.bytecode) && + isBytecode(artifact.deployedBytecode) && + matchesCompilationTarget(artifact.metadata, sourceFilename, filename) + ); + } catch { + // Malformed, ambiguous, oversized, unreadable, or changing files are not + // proven generated output; keep them on the normal fail-closed path. + return false; + } +} + +function isBytecode(value: unknown): boolean { + return isRecord(value) && typeof value.object === "string"; +} + +function matchesCompilationTarget(metadata: unknown, sourceFilename: string, filename: string): boolean { + if (!isRecord(metadata) || metadata.language !== "Solidity") return false; + if (!isRecord(metadata.compiler) || typeof metadata.compiler.version !== "string") return false; + if (!isRecord(metadata.settings) || !isRecord(metadata.settings.compilationTarget)) return false; + const targets = Object.entries(metadata.settings.compilationTarget); + return ( + targets.length === 1 && + targets.some( + ([source, type]) => + typeof type === "string" && path.posix.basename(source) === sourceFilename && `${type}.json` === filename + ) + ); } diff --git a/packages/runtime/test/fixtures/foundry-artifact.ts b/packages/runtime/test/fixtures/foundry-artifact.ts new file mode 100644 index 000000000..13b9eebe1 --- /dev/null +++ b/packages/runtime/test/fixtures/foundry-artifact.ts @@ -0,0 +1,86 @@ +// Captured from Forge 1.7.1 / solc 0.8.37 compiling this synthetic test: +// contract Harness { function testExample() public pure { assert(true); } } +// Keep the compiler's artifact structure in regression tests without requiring +// a Forge executable or a compiler download in the runtime test environment. +export const foundryHarnessArtifact = { + abi: [ + { + type: "function", + name: "testExample", + inputs: [], + outputs: [], + stateMutability: "pure" + } + ], + bytecode: { + object: + "0x6080604052348015600e575f5ffd5b5060a380601a5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c80633f5a4a2a14602a575b5f5ffd5b60306032565b005b6001603e57603d6040565b5b565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52600160045260245ffdfea2646970667358221220d43bd887622029247fcfcb18cf2da1bb8e8b32d25cbf9e37d5881e92d60720bd64736f6c63430008250033", + sourceMap: "56:79:1:-:0;;;;;;;;;;;;;;;;;;;", + linkReferences: {} + }, + deployedBytecode: { + object: + "0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c80633f5a4a2a14602a575b5f5ffd5b60306032565b005b6001603e57603d6040565b5b565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52600160045260245ffdfea2646970667358221220d43bd887622029247fcfcb18cf2da1bb8e8b32d25cbf9e37d5881e92d60720bd64736f6c63430008250033", + sourceMap: + "56:79:1:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;79:54;;;:::i;:::-;;;125:4;118:12;;;;:::i;:::-;;79:54::o;7:180:2:-;55:77;52:1;45:88;152:4;149:1;142:15;176:4;173:1;166:15", + linkReferences: {} + }, + methodIdentifiers: { + "testExample()": "3f5a4a2a" + }, + rawMetadata: + '{"compiler":{"version":"0.8.37+commit.f401782d"},"language":"Solidity","output":{"abi":[{"inputs":[],"name":"testExample","outputs":[],"stateMutability":"pure","type":"function"}],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"compilationTarget":{"test/Harness.t.sol":"Harness"},"evmVersion":"osaka","libraries":{},"metadata":{"bytecodeHash":"ipfs"},"optimizer":{"enabled":false,"runs":200},"remappings":[]},"sources":{"test/Harness.t.sol":{"keccak256":"0x9cb160a30924af445aa5ff55a744a9e1dc01ab5d6266767f59d007147030212e","license":"MIT","urls":["bzz-raw://a0c4bf7185513d717cabd37e06c1d58b759669af36e59bd2dc9fcb2fc35e7359","dweb:/ipfs/QmSogmbrsgc1QQDpG6uymmUcWcHWXbcEBZY91XPzEKWLTa"]}},"version":1}', + metadata: { + compiler: { + version: "0.8.37+commit.f401782d" + }, + language: "Solidity", + output: { + abi: [ + { + inputs: [], + stateMutability: "pure", + type: "function", + name: "testExample" + } + ], + devdoc: { + kind: "dev", + methods: {}, + version: 1 + }, + userdoc: { + kind: "user", + methods: {}, + version: 1 + } + }, + settings: { + remappings: [], + optimizer: { + enabled: false, + runs: 200 + }, + metadata: { + bytecodeHash: "ipfs" + }, + compilationTarget: { + "test/Harness.t.sol": "Harness" + }, + evmVersion: "osaka", + libraries: {} + }, + sources: { + "test/Harness.t.sol": { + keccak256: "0x9cb160a30924af445aa5ff55a744a9e1dc01ab5d6266767f59d007147030212e", + urls: [ + "bzz-raw://a0c4bf7185513d717cabd37e06c1d58b759669af36e59bd2dc9fcb2fc35e7359", + "dweb:/ipfs/QmSogmbrsgc1QQDpG6uymmUcWcHWXbcEBZY91XPzEKWLTa" + ], + license: "MIT" + } + }, + version: 1 + }, + id: 1 +}; diff --git a/packages/runtime/test/workspace-generated-output.test.ts b/packages/runtime/test/workspace-generated-output.test.ts index a6767a978..e14f4c260 100644 --- a/packages/runtime/test/workspace-generated-output.test.ts +++ b/packages/runtime/test/workspace-generated-output.test.ts @@ -6,12 +6,14 @@ import test from "node:test"; import { assertArtifactPublicationsContainNoSecrets } from "@ultrafuzz/artifacts"; +import { isGeneratedWorkspaceOutput } from "../src/workspace-generated-output.js"; import { applyWorkspacePatch, captureWorkspacePatch, captureWorkspaceTree, validateWorkspacePatchCapture } from "../src/workspace-handoff.js"; +import { foundryHarnessArtifact } from "./fixtures/foundry-artifact.js"; import { captureLegacyWorkspacePatch } from "./fixtures/legacy-workspace-patch.js"; import { temporaryRoot } from "./temporary-root.js"; @@ -38,12 +40,28 @@ function fixture(): string { const compilerDump = JSON.stringify({ input: { language: "Solidity", sources: {} }, output: { contracts: {} } }); +function compilerArtifact(source: string, type: string, name = "testExample"): string { + const abi = foundryHarnessArtifact.abi.map((entry) => ({ ...entry, name })); + const metadata = { + ...foundryHarnessArtifact.metadata, + output: { ...foundryHarnessArtifact.metadata.output, abi }, + settings: { ...foundryHarnessArtifact.metadata.settings, compilationTarget: { [source]: type } } + }; + return JSON.stringify({ + ...foundryHarnessArtifact, + abi, + metadata, + rawMetadata: JSON.stringify(metadata), + methodIdentifiers: { [`${name}()`]: "3f5a4a2a" } + }); +} + test("classifies nested build, coverage and corpus output without dropping neighboring authored files", () => { const root = fixture(); const downstream = path.join(temporaryRoot("ultrafuzz-generated-downstream-"), "workspace"); const baseline = captureWorkspaceTree(root); const generated: Record = { - ".cov-work/out/Fixture.sol/Fixture.json": "{}", + ".cov-work/out/Fixture.sol/Fixture.json": compilerArtifact("src/Fixture.sol", "Fixture"), ".cov-work/compilation.json": compilerDump, ".recon/build.json": compilerDump, ".recon/compiler-input.json": compilerDump, @@ -57,7 +75,7 @@ test("classifies nested build, coverage and corpus output without dropping neigh "test/recon/echidna/coverage/seed.txt": "corpus", "custom-output/out/build-info/0123456789abcdef.json": "{}", ".recon/build-snapshot/0123456789abcdef.json": "{}", - "custom-output/out/Fixture.sol/Fixture.json": "{}", + "custom-output/out/Fixture.sol/Fixture.json": compilerArtifact("src/Fixture.sol", "Fixture"), ".cov-work/cache/solidity-files-cache.json": "{}" }; const authored: Record = { @@ -78,7 +96,9 @@ test("classifies nested build, coverage and corpus output without dropping neigh "test/fixtures/build-snapshot/config.json": '{"workers":1}\n', "test/fixtures/build-info/0123456789abcdef.json": '{"cases":[1,2,3]}\n', "test/fixtures/out/Fixture.sol/cases.json": '{"cases":[1,2,3]}\n', - "custom-output/Fixture.sol/Fixture.json": compilerDump + "test/fixtures/out/Fixture.sol/Fixture.json": '{"cases":[1,2,3]}\n', + ".cov-work/out/Fixture.sol/cases.json": '{"cases":[1,2,3]}\n', + "custom-output/Fixture.sol/Fixture.json": compilerArtifact("src/Fixture.sol", "Fixture") }; for (const [filename, contents] of Object.entries({ ...generated, ...authored })) write(root, filename, contents); @@ -99,6 +119,115 @@ test("classifies nested build, coverage and corpus output without dropping neigh } }); +test("recognizes Foundry test, script, interface and multiple-type artifacts without source/type basename equality", () => { + const root = fixture(); + const baseline = captureWorkspaceTree(root); + const layouts = [ + ["test/Harness.t.sol", "Harness"], + ["script/Deploy.s.sol", "Deploy"], + ["src/Fixture.sol", "Fixture"], + ["src/Fixture.sol", "DifferentName"], + ["src/Interfaces.sol", "IFixture"] + ] as const; + for (const directory of [".cov-work/out", ".recon/out", "nested/out"]) { + for (const [source, type] of layouts) { + write(root, `${directory}/${path.basename(source)}/${type}.json`, compilerArtifact(source, type)); + } + } + // Abstract contracts/interfaces have empty bytecode; it is still generated. + write( + root, + ".cov-work/out/Interfaces.sol/IFixture.json", + JSON.stringify({ + ...foundryHarnessArtifact, + bytecode: { object: "0x" }, + deployedBytecode: { object: "0x" }, + metadata: { + ...foundryHarnessArtifact.metadata, + settings: { compilationTarget: { "src/Interfaces.sol": "IFixture" } } + } + }) + ); + write(root, "test/Harness.t.sol", "contract Harness {}\n"); + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files, [{ path: "test/Harness.t.sol" }]); + assert.equal(captured.manifest.excluded_files, undefined); + assert.equal(captureWorkspaceTree(root), captured.manifest.result_tree); +}); + +test("keeps real-layout Foundry metadata out of the fail-closed secret gate below the patch limit", () => { + const root = fixture(); + const configuredSecret = "fixture-configured-credential-value"; + const fakeCredential = "ghp_AbCdEf1234567890AbCdEf1234567890AbCd"; // gitleaks:allow -- fixed synthetic scanner fixture + write(root, "src/Fixture.sol", `contract DifferentName { function ${fakeCredential}() public pure {} }\n`); + git(root, ["add", "src/Fixture.sol"]); + git(root, ["commit", "--quiet", "-m", "synthetic compiler input"]); + const baseline = captureWorkspaceTree(root); + write(root, "test/Harness.t.sol", "contract Harness { function testExample() public pure { assert(true); } }\n"); + write(root, ".cov-work/out/Harness.t.sol/Harness.json", JSON.stringify(foundryHarnessArtifact)); + write(root, ".cov-work/out/Fixture.sol/Fixture.json", compilerArtifact("src/Fixture.sol", "Fixture")); + write( + root, + ".cov-work/out/Fixture.sol/DifferentName.json", + compilerArtifact("src/Fixture.sol", "DifferentName", fakeCredential) + ); + const legacy = captureLegacyWorkspacePatch(root, baseline); + assert.ok(Buffer.byteLength(legacy.patch) < 16 * 1024 * 1024); + assert.equal(legacy.patch.includes(configuredSecret), false); + assert.throws( + () => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(legacy.patch)]]), [ + configuredSecret + ]), + /artifact publication contains sensitive data/u + ); + + const captured = captureWorkspacePatch(root, baseline); + assert.deepEqual(captured.manifest.files, [{ path: "test/Harness.t.sol" }]); + assert.ok(Buffer.byteLength(captured.patch) < 1024); + assert.doesNotThrow(() => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(captured.patch)]]), [ + configuredSecret + ]) + ); + validateWorkspacePatchCapture(root, captured, ["src", "contracts"]); +}); + +test("retains ambiguous compiler-looking JSON and bounds inspection of oversized candidates", () => { + const root = fixture(); + const baseline = captureWorkspaceTree(root); + const candidates = [ + "{}", + "{incomplete", + JSON.stringify({ ...foundryHarnessArtifact, abi: null }), + JSON.stringify({ ...foundryHarnessArtifact, bytecode: "0x6000" }), + JSON.stringify({ ...foundryHarnessArtifact, deployedBytecode: null }), + JSON.stringify({ ...foundryHarnessArtifact, metadata: null }), + compilerArtifact("test/Other.t.sol", "Harness"), + compilerArtifact("test/Harness.t.sol", "Other"), + JSON.stringify(foundryHarnessArtifact).replace('"abi":', '"abi":[],"abi":') + ]; + const paths = candidates.map((contents, index) => { + const filename = `case-${index}/out/Harness.t.sol/Harness.json`; + write(root, filename, contents); + assert.equal(isGeneratedWorkspaceOutput(root, filename), false, filename); + return filename; + }); + assert.deepEqual( + captureWorkspacePatch(root, baseline).manifest.files.map((entry) => entry.path), + paths.sort() + ); + + const oversized = "large/out/Harness.t.sol/Harness.json"; + write(root, oversized, JSON.stringify(foundryHarnessArtifact)); + fs.truncateSync(path.join(root, oversized), 1024 * 1024 * 1024); + assert.equal( + isGeneratedWorkspaceOutput(root, oversized), + false, + "oversized candidates stay eligible for measured overflow recovery" + ); +}); + test("keeps a below-limit generated compiler dump out of the fail-closed artifact secret gate", () => { const root = fixture(); const baseline = captureWorkspaceTree(root); @@ -173,6 +302,40 @@ test("replays authored scratch JSON across generations even when it acquires com assert.equal(fs.readFileSync(path.join(downstream, ".recon/config.json"), "utf8"), compilerDump); }); +test("preserves authenticated authored JSON when it becomes a compiler artifact, including secrets and deletions", () => { + const root = fixture(); + const downstream = path.join(temporaryRoot("ultrafuzz-generated-shaped-chain-"), "workspace"); + const filename = ".cov-work/out/Harness.t.sol/Harness.json"; + const baseline = captureWorkspaceTree(root); + write(root, filename, '{"cases":[1,2,3]}\n'); + const first = captureWorkspacePatch(root, baseline); + assert.deepEqual(first.manifest.files, [{ path: filename }]); + write(root, filename, JSON.stringify(foundryHarnessArtifact)); + const second = captureWorkspacePatch(root, first.manifest.result_tree); + assert.deepEqual(second.manifest.files, [{ path: filename }]); + git(root, ["worktree", "add", "--quiet", "--detach", downstream, "HEAD"]); + applyWorkspacePatch(downstream, first, ["src", "contracts"]); + applyWorkspacePatch(downstream, second, ["src", "contracts"]); + assert.equal(captureWorkspaceTree(downstream, { trees: [second.manifest.result_tree] }), second.manifest.result_tree); + + const configuredSecret = "fixture-configured-credential-value"; + write(root, filename, compilerArtifact("test/Harness.t.sol", "Harness", configuredSecret)); + const contaminated = captureWorkspacePatch(root, second.manifest.result_tree); + assert.deepEqual(contaminated.manifest.files, [{ path: filename }]); + assert.throws( + () => + assertArtifactPublicationsContainNoSecrets(new Map([["workspace.patch", Buffer.from(contaminated.patch)]]), [ + configuredSecret + ]), + /artifact publication contains sensitive data/u + ); + fs.unlinkSync(path.join(root, filename)); + const deleted = captureWorkspacePatch(root, second.manifest.result_tree); + assert.deepEqual(deleted.manifest.files, [{ path: filename }]); + applyWorkspacePatch(downstream, deleted, ["src", "contracts"]); + assert.equal(fs.existsSync(path.join(downstream, filename)), false); +}); + test("replays retained v1 generated entries and preserves their authority across updates and deletions", () => { const root = fixture(); const downstream = path.join(temporaryRoot("ultrafuzz-legacy-downstream-"), "workspace"); @@ -180,6 +343,7 @@ test("replays retained v1 generated entries and preserves their authority across git(root, ["worktree", "add", "--quiet", "--detach", downstream, "HEAD"]); write(root, ".recon/build.json", "{}\n"); write(root, "lcov.info", "legacy coverage\n"); + write(root, ".cov-work/out/Harness.t.sol/Harness.json", JSON.stringify(foundryHarnessArtifact)); write(root, "test/Harness.t.sol", "contract Harness {}\n"); const first = captureLegacyWorkspacePatch(root, baseline); validateWorkspacePatchCapture(downstream, first, ["src", "contracts"]); @@ -219,6 +383,10 @@ test("replays retained v1 generated entries and preserves their authority across assert.equal(captureWorkspaceTree(downstream, { trees: [third.manifest.result_tree] }), third.manifest.result_tree); assert.equal(fs.existsSync(path.join(downstream, ".recon/build.json")), false); assert.equal(fs.readFileSync(path.join(downstream, "lcov.info"), "utf8"), "legacy coverage\n"); + assert.equal( + fs.readFileSync(path.join(downstream, ".cov-work/out/Harness.t.sol/Harness.json"), "utf8"), + JSON.stringify(foundryHarnessArtifact) + ); }); test("still rejects configured and credential-shaped secrets in included authored files", () => { @@ -232,7 +400,9 @@ test("still rejects configured and credential-shaped secrets in included authore ".recon/config.json", "test/Replay.t.sol", "test/recon/out/config.json", - "test/fixtures/Fixture.sol/cases.json" + "test/fixtures/Fixture.sol/cases.json", + ".cov-work/out/Fixture.sol/Fixture.json", + "test/fixtures/out/Fixture.sol/cases.json" ]) { const root = fixture(); const baseline = captureWorkspaceTree(root); @@ -259,6 +429,12 @@ test("leaves invalid scratch JSON and unsafe generated-looking paths to normal v fs.symlinkSync("foundry.toml", path.join(root, "lcov.info")); assert.throws(() => captureWorkspacePatch(root, baseline), /symlink/u); fs.unlinkSync(path.join(root, "lcov.info")); + const artifact = ".cov-work/out/Harness.t.sol/Harness.json"; + fs.mkdirSync(path.dirname(path.join(root, artifact)), { recursive: true }); + fs.symlinkSync(path.join(root, "foundry.toml"), path.join(root, artifact)); + assert.equal(isGeneratedWorkspaceOutput(root, artifact), false); + assert.throws(() => captureWorkspacePatch(root, baseline), /symlink/u); + fs.unlinkSync(path.join(root, artifact)); write(root, ".recon/.env.json", compilerDump); assert.throws(() => captureWorkspacePatch(root, baseline), /secret|sensitive|\.env/u); }); From 7f893cb312d3a559826c31cc3a1855fae4527714 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Thu, 15 Oct 2026 12:53:15 +0000 Subject: [PATCH 4/4] test(runtime): require tree authority in generated verifier --- .../runtime/test/generated-workflow-verifier.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/runtime/test/generated-workflow-verifier.test.ts b/packages/runtime/test/generated-workflow-verifier.test.ts index 1aad7b4cd..5736684bb 100644 --- a/packages/runtime/test/generated-workflow-verifier.test.ts +++ b/packages/runtime/test/generated-workflow-verifier.test.ts @@ -10232,7 +10232,15 @@ test("generated Smithers preserves setup-patch baselines across post-agent prepa // R49) died at `prepare:stateful-invariant-implement-properties` without this. assert.match(source, /function firstDependencyRequiringReplay\(/u); assert.match(helper, /replayWorkspacePatches && captures\.length > 0/u); - assert.match(helper, /firstDependencyRequiringReplay\(\s*captureWorkspaceTree\(workspaceRoot\),/u); + assert.match(helper, /firstDependencyRequiringReplay\(\s*captureWorkspaceTree\(workspaceRoot, treeAuthority\),/u); + // #1308: every tree reconstruction retains authenticated legacy membership, + // including preparation state, without admitting unrelated new scratch paths. + assert.match(helper, /const captures = authenticatedWorkspacePatchCaptures\(task, workspaceRoot\);/u); + assert.match(helper, /const treeAuthority = \{\s*trees: \[/u); + assert.match(helper, /\.\.\.captures\.map\(\(capture\) => capture\.manifest\.base_tree\)/u); + assert.match(helper, /\.\.\.\(persistedPreparation === undefined \? \[\] : \[persistedPreparation\]\)/u); + assert.match(helper, /files: captures\.flatMap\(\(capture\) => capture\.manifest\.files\)/u); + assert.doesNotMatch(helper, /captureWorkspaceTree\(workspaceRoot\)/u); assert.match(helper, /captures\.map\(\(entry\) => entry\.manifest\)/u); // Every capture is validated even when replay skips it: the manifest schema, object ids, digest and // sensitive-path checks all live inside `applyWorkspacePatch`, so a skipped patch would otherwise go