Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,28 @@ 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/<source>.sol/<type>.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.<number>.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, 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. 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.

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`.
Expand Down
22 changes: 16 additions & 6 deletions packages/runtime/src/templates/smithers/workflows/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -3436,7 +3446,7 @@ function materializeWorkspacePatchDependencies(
const replayFrom =
replayWorkspacePatches && captures.length > 0
? firstDependencyRequiringReplay(
captureWorkspaceTree(workspaceRoot),
captureWorkspaceTree(workspaceRoot, treeAuthority),
captures.map((entry) => entry.manifest)
)
: 0;
Expand All @@ -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);
Expand All @@ -3455,15 +3465,15 @@ function materializeWorkspacePatchDependencies(
writeWorkspacePreparationAuthority(
task.runRoot,
task.attemptId,
captureWorkspaceTree(workspaceRoot),
captureWorkspaceTree(workspaceRoot, treeAuthority),
admittedWorkspacePreparationDependencyDigest(task)
);
}
if (!workspacePatchPreparationTrees.has(task.attemptId)) {
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);
Expand All @@ -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);
Expand Down Expand Up @@ -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}`
);
Expand Down
124 changes: 124 additions & 0 deletions packages/runtime/src/workspace-generated-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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([
"build.json",
"build-info.json",
"compilation.json",
"compiler-input.json",
"compiler-output.json",
"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
* 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.
* 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) ?? "";
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)))
);
}

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")
);
}

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.includes("corpus")))
);
}

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
)
);
}
67 changes: 55 additions & 12 deletions packages/runtime/src/workspace-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -156,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();
});
}
Expand Down Expand Up @@ -430,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}`);
Expand All @@ -444,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}`
Expand Down Expand Up @@ -498,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<string, Buffer> } {
function stageWorkspaceTree(
workspaceRoot: string,
index: string,
preservedPaths: ReadonlySet<string> = new Set()
): { untrackedPaths: ReadonlyMap<string, Buffer> } {
// 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(
Expand All @@ -528,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<string>
): { pathspecs: Buffer[]; untrackedPaths: ReadonlyMap<string, Buffer> } {
// 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 <baseline>`,
Expand All @@ -555,7 +582,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}/**`)
],
Expand Down Expand Up @@ -584,6 +612,21 @@ function stageableWorkspacePaths(
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.
if (Buffer.from(relativePath).equals(entry)) {
try {
normalizeWorkspacePatchPath(relativePath);
rejectSensitivePath(relativePath);
} catch {
pathspecs.push(entry);
untrackedPaths.set(key, entry);
return;
}
if (!preservedPaths.has(key) && isGeneratedWorkspaceOutput(workspaceRoot, relativePath)) return;
}
}
pathspecs.push(entry);
if (isUntracked) untrackedPaths.set(key, entry);
};
Expand Down
Loading
Loading