Subdivide walker_ms with a new encode_ms field that times the
outermost encodeExpr / encodeCondition / applyStmt /
encodeConditionWithShortCircuit calls from the SecurityWalker
(8 call sites, each wrapped with a ScopedEncodeTimer RAII helper).
For null-deref on the curl baseline:
wall_ms: 274,332
walker_ms: 270,956
encode_ms: 6,451 (2.4% of walker)
state_merge_ms: 0
walker-encode-merge: 264,505 (97.6% — bookkeeping + hooks)
This is the third pivot in two sessions of perf-audit work. The
encoder hypothesis is wrong: 264s of the 271s walker time is NOT
in encodeExpr family. The actual cost lives in walker per-stmt
bookkeeping (range/octagon refinement, M22/M23/M25/M26/M27/M28
non-null marking, CFG block lookup, branch liveness) and in the
hooks' SAT-prep work (assumption-vector construction, expression
hashing, multi-model sampling from commit 4c343d4).
Z3 itself remains tiny (~33ms across the entire scan).
Updated docs/perf-audit.md item #2 with the corrected numbers and
the next-round remediation hypothesis: add hook_ms next; expect
it to dominate.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subdivide the security-detector wall_ms bucket into walker time and
merge time (audit backlog item #3). For null-deref's 287s wall on
the curl baseline the new fields surface a surprise:
null-deref wall_ms: 286,610
walker_ms: 283,206 (98.8% — all in walkCFG/walk)
state_merge_ms: 142 (0.05% — merge is NOT the target)
walker - merge: 283,064 (encoder + hook dispatch)
The state-merge hypothesis in the original audit (env copy/diff at
if-joins as the cost driver) is empirically wrong; merge is
essentially free. The 283s lives in encoder calls + observer hook
dispatch + RecursiveASTVisitor traversal.
Updated docs/perf-audit.md item #2 with the corrected numbers and a
narrower remediation sketch: subdivide walker_ms further into
visit_ms / encode_ms / hook_ms in the next round.
Mechanics:
- SolverStats gains walker_ms + state_merge_ms (std::chrono::ms).
- SecurityWalker reuses its existing walk_start clock to populate
stats_->walker_ms; new merge timer wraps the resolved-binding
block in walkIf.
- applySolverStats propagates both into DetectorProfile.
- profile.cpp emits both fields in --profile JSON.
New baseline: docs/perf-audit/baseline-phases-2026-05-23.json.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a "What fold reliably catches vs what's noise" section informed
by recent real-codebase usage. Two concrete claims now in the doc:
1. On tight, well-typed codebases most security findings are FPs
because the bounded symbolic model can't carry domain invariants
the codebase enforces elsewhere.
2. The reliable signal is the CLI-input → arithmetic-hole pattern.
Where untrusted input flows into integer math or pointer
arithmetic without a bounds check, fold catches it; the fix is
usually a one-line defensive check at the boundary.
Plus a four-step triage heuristic users can apply when reading a
report: filter by input reachability; trust ConcreteWitness;
triage Proved against reachability; heavily filter Heuristic.
Recommends `--detectors security` (which includes taint-track) as
the mechanical version of the input-reachability check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit doc citing the new ScanProfile instrumentation, the curl
baseline, the fold-on-fold static-analysis pass, and a Phase-2
backlog ranked by observed cost rather than guesses.
Headline findings:
- The plan's "highest-leverage" target (5-walk hash fusion in the
clone phase) turned out to be 0.04% of total wall on curl.
Pivoted to instrumentation; doc records the pivot honestly.
- New named bucket `serial_dispatch_ms - sum(detector.wall_ms)`
exposes 146s (32%) of previously-unaccounted wall, almost
entirely clang ASTUnit construction per TU.
- null-deref dominates detector wall at 65% of total. ~99% of
that is encoder + state-merging, not Z3.
- Per-phase null-deref instrumentation is now item #3 in the
backlog; PCH for project headers is item #1.
Cross-references the instrumentation commit (503799a) and the
fold-on-fold pass (previous commit) so consumers can reproduce
all numbers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Run fold against its own 134-TU compile DB with
--detectors clone,dead-statement,equiv --clone-type 3.
Results:
- 487 clone findings (Type-1/2/3, 80%+ similarity)
- 152 dead-statement findings
- 0 equiv findings (signature-bucket misses; scout-rule extractions
moved colliding helpers out)
docs/perf-audit/fold-on-fold.md triages the top duplication clusters:
- `mixHash` 5+ copies across security-detector anonymous namespaces
- `supportsParam` / `collectConjuncts` repeated in many detectors
- `onFunctionEnd` boilerplate across all 5 security observers
- `ir::*::valid` / `operator bool` / `operator==` per-type boilerplate
- Test-fixture duplication (intentional; not a refactor target)
Plus a triage suggestion for the 152 dead-statement findings (cache
short-circuits usually safe; encoder bails usually intentional).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add three new phase-level timings to ScanProfile:
- compile_db_load_ms: loadCompilationDatabase + buildSyntheticCompileDB
- fanout_orchestration_ms: SubprocessFanout::runSync wall (spawn + IPC + wait)
- serial_dispatch_ms: in-process runDetectorsOnShard wall (--profile forces
serial today; this is the dominant phase when profiling)
Motivation: the baseline curl scan showed sum(detector.wall_ms) = 298s
out of 455s total wall, leaving 157s (34%) unaccounted. With this
instrumentation the user can see at a glance where the gap lives.
For the captured baseline (curl, 306 TUs, 2339 functions, --profile,
serial):
total_wall_ms: 455_756
compile_db_load_ms: 2 (negligible)
fanout_orchestration_ms: 0 (not used in profile mode)
serial_dispatch_ms: 455_682
detector wall_ms sum: 298_361
unaccounted in dispatch: 157_321 ← clang AST build + cross-TU priming
+ call-graph build + fingerprinting
Phase-2 work will subdivide the 157s further. This commit just
surfaces it as a named metric in the JSON.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
C4 (commit 2703ff9) set r2's log.level=4 + log.quiet=true to
silence INFO/WARN. Live verification on /bin/ls showed that
wasn't enough: r2's progress meter ("> aae: 0%" repeated for
every step of the analysis) writes directly to STDOUT/STDERR
fds, bypassing the logging configuration. A short scan still
emitted ~30KB of progress chatter.
Fix: in R2Session, dup STDOUT_FILENO and STDERR_FILENO to
saved fds, redirect both to /dev/null, run `aaa`, then restore.
Subsequent spdlog::error / MCP frames work normally because we
restore the fds immediately on return.
Also add a few more config knobs that don't cost anything but
help on r2 builds that respect them: scr.interactive=false,
scr.prompt=false, cfg.fortunes=false.
Verified end-to-end on /bin/ls:
- `fold scan --mode=binary --binary /bin/ls ...` emits only
fold's own "[info] emitted N findings total" line; zero
r2 chatter (was ~30KB).
- `--quiet` mode produces completely silent stderr.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs/cli-philosophy.md principle 12 documents a stable
exit-status contract: 0 (no findings), 1 (findings present),
2 (input/config error). C2 introduced parse-time --detectors
validation, but CLI11's default error path returns its own
per-error-class codes (105 ValidationError, 106 ConversionError,
110 ExtrasError, ...), violating the contract.
Replace the CLI11_PARSE macro with a hand-rolled try/catch that
invokes app.exit(e) (so CLI11 still prints its message) and then
remaps any non-zero return to 2. --help / --version continue to
exit 0 because CLI11 returns rc=0 for those.
Verified:
- bogus --detectors → exit 2 (was 105)
- bad --mode value → exit 2 (was 105)
- valid scan, no findings → exit 0
- --help / --version → exit 0
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 (C4) of the CLI design philosophy refactor. Closes audit
item #6 from docs/cli-philosophy.md and the original task #209.
Today `fold scan --mode=binary` leaks r2's own analysis chatter
("INFO: Analyze all flags starting with sym. and entry0 (aa)",
"WARN: Cannot find basic block case for jmptbl switch from
0x10225a174 bbdelta = 68") through fold's stderr. The CLI
fallback path (runCliTextCommand) already pipes 2>/dev/null,
but the in-process libr_core path inherits fd2 from us.
Fix: set `e log.level=4` (ERROR-class only) and `e log.quiet=true`
in R2Session's constructor before `aaa`. Real error-class messages
still surface; INFO/WARN are dropped.
Verified: 44/44 tests still green. End-to-end binary-scan
verification (against a user-supplied binary) is the acceptance
criterion in #209; this commit ships the source change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 (C3) of the CLI design philosophy refactor. Addresses
audit item #5 from docs/cli-philosophy.md.
Today scan_router throws std::invalid_argument /
std::runtime_error on bad inputs (missing --binary, unknown mode,
no compile DB, etc.). runScan catches them and logs "scan failed:
<msg>" — the user sees what failed but not how to recover.
After this change, those throw sites use the new
fold::cli::UserError (subclass of std::runtime_error carrying a
vector of recovery hints). runScan's catch dispatches on
UserError first and formats via emitUserError, which prints:
<message>
Try:
<hint 1>
<hint 2>
All hints are exact, copy-pasteable command lines — no
placeholders. Applied at six throw sites: unsupported mode,
no compile DB, empty compile DB, rust mode without --paths,
binary mode without --binary, unsupported hybrid strategy.
New files:
- include/fold/cli/error_format.hpp: UserError exception type +
emitErrorWithRecovery / emitUserError helpers.
- src/cli/error_format.cpp: implementation.
Wired:
- src/cli/scan_router.cpp: throws now carry hints.
- src/cli/main.cpp::runScan: catches UserError first.
- meson.build: error_format.cpp in fold_core_sources.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 (C2) of the CLI design philosophy refactor. Addresses
audit item #4 from docs/cli-philosophy.md.
Today --detectors / --source-detectors / --binary-detectors all
accept any string and silently drop unknown names downstream.
After this change CLI11 rejects unknown names at parse time with
exit code 105 (ValidationError) and a full list of valid names.
New files:
- include/fold/cli/detector_registry.hpp: canonical names +
validator + family helpers (sourceLeafDetectors,
binaryLeafDetectors).
- src/cli/detector_registry.cpp: the table (single source of
truth across CLI11, scan_router family expansion, and any
future MCP-tool routing per principle 9).
Wiring:
- main.cpp: new appendValidatedDetectors() helper validates
every name via the registry before pushing into ScanOptions.
Throws CLI::ValidationError with the registry's "did you
mean" message on rejection so the user sees the full valid
set inline. Applied to all three detector flags.
- meson.build: registry compiled into fold-core (used by both
the CLI executable and downstream tests).
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 (C1) of the CLI design philosophy refactor. Addresses
audit items #1–#3 from docs/cli-philosophy.md.
Changes:
- Add top-level `--quiet`/`-q` flag. Lowers spdlog to WARN level
so INFO chatter ("running N worker process(es)...", per-shard
clone profile lines) is suppressed. Per principle 12, --quiet
suppresses human chatter only; required machine output
(findings, NDJSON, MCP frames) is unaffected.
- Hide `worker` subcommand from --help via CLI11's group("").
Stays callable so SubprocessFanout::runSync can spawn instances
(subprocess_fanout integration test confirms).
- Add `fuzz` parent group with `prep` and `run` children.
Canonical paths are now `fold fuzz prep` and `fold fuzz run`.
Legacy top-level `fuzz-prep` / `fuzz-run` remain as hidden
aliases for back-compat; emit a deprecation warning when
invoked and direct users to the new path.
- Dispatch switched from `app.got_subcommand(child)` to
`child->parsed()` because CLI11 throws OptionNotFound when
looking up a child whose parent isn't on the invocation chain
(regression caught by the worker test).
`fold --help` now shows: scan, confirm, fuzz, mcp, lsp.
`fold fuzz --help` shows: prep, run.
`fold worker --help` still works for the fanout pattern but is
no longer in the top-level listing.
44/44 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Durable policy doc that every CLI change reviews against. Adapted
from sage's CLI methodology, fold-specific.
Sections: purpose; workflow stages + canonical verb table
(scan/show/confirm/fuzz prep+run/report/serve/worker); 13 design
principles (one canonical path per intent, lifecycle layering,
scan-vs-confirm separation, help that teaches the graph, errors
that recover, parse-time validation, non-interactive first-class,
machine-readable stability, metadata from the real graph,
verification on the built binary, deliberate back-compat, scriptable
streams, literature as constraint); required review checklist;
preferred flow patterns; anti-patterns; audit appendix.
The audit appendix names 13 anti-patterns observable in fold's CLI
today; #1-#6 are addressed by the Phase-1 refactor commits in this
series (C1: --quiet + hide worker + fuzz group, C2: detector parse
validation, C3: errorWithRecovery, C4: r2 noise filter). #7-#13
are tracked as Phase-2 follow-ups.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First non-clang frontend for fold. Scans Rust source for the
subset of issues that meaningfully exist there: raw-pointer
dereferences in `unsafe` blocks (Heuristic confidence) and
within-Rust Type-1/2/3 clones via the existing clone detector.
Reuses the M30 frontend-agnostic IR + AnalysisBackend
abstraction unchanged.
Architecture: a separate `tools/fold-rust` cargo binary that the
C++ `RustBackend` shells out to. Two subprocess invocation
modes:
- `extract --unit <file>`: standalone .rs file, no Cargo
context (limited; external-crate imports won't resolve).
- `cargo-check --crate-dir <dir>`: invokes `cargo check` with
RUSTC_WORKSPACE_WRAPPER set to ourselves so cargo supplies
the full --extern/-L/--cfg per-TU. Mirrors the
clippy/miri/kani pattern.
RustBackend::discoverUnits walks up from each `.rs` input to
find the nearest Cargo.toml and collapses sibling files to one
crate unit; orphan files fall back to single-file extract.
scan_router auto-detects --mode=rust when all paths are `.rs`;
mixed Rust + C/C++ warns and defaults to source mode.
Includes:
- tools/fold-rust/: cargo crate pinned to nightly-2026-05-22.
HIR-driven DerefHunter (raw-pointer dereference detection
via typeck `is_raw_ptr()`) and HashWalker (FNV-1a + mix64
port of src/ir/normalize.cpp for FunctionSummary hashes).
- include/fold/ir/expr_json.{hpp,cpp}: RustExprNode +
RustNodeArena + bidirectional JSON wire format for
ir::Expr; round-trip tested across 8 Kinds.
- include/fold/frontend/rust_backend.{hpp,cpp}: AnalysisBackend
impl. posix_spawnp + NDJSON parser. Caches summaries +
findings across extractFunctions calls; polymorphic
runDetectorsOnShard dispatch routes summaries to
detectClones.
- src/cli/{main,scan_router,scan_runner}.cpp: --mode=rust
branch + autoDetectRust(). Mode validation extended to
accept rust.
- docs/rust-frontend.md: scope, wire format, rustc-private
pin + bump procedure, verification snippets.
44/44 unit + integration tests still green. Verified
end-to-end on a 3-function rename-pair fixture: 1 clone
cluster + 1 raw-deref finding through both `extract` and
`cargo-check` paths.
Out of scope (tracked separately): equiv + class-dup
detectors (need encoder frontend-agnostification),
dead-statement (needs Rust CFG), cross-language clones
(needs unified kind-tag taxonomy), Proved/ConcreteWitness
promotion (needs encoder integration), `cargo fuzz`
harnesses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two long-pending docs:
- docs/positioning.md: sales-free comparison of fold against
clang-tidy, commercial SAST, CodeQL/Joern, CBMC/ESBMC, and
KLEE/SymCC/S2E. Tells engineers when to choose fold and
when not to. Closes task #88.
- docs/m31-phase3-concolic.md: scoping for the closed-loop
symbolic execution piece of M31 Phase 3. Compares
vendoring SymCC's LLVM pass (Option A) vs the LibAFL +
symcc-rt bridge (Option B). Recommends Option B for
maintenance leverage; stages P3.1 (plumbing), P3.2
(LibAFL harness), P3.3 (promotion path).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When both arms of an if-statement prove the same variable non-null
(via M22 null-check inference or M23 address-of marking), the
summary encoder's `mergeBindings` now intersects the two branches'
nonNullVars sets and marks the survivors in the post-if env.
Mirrors the same logic already present in
security_walker.cpp::walkIf so summary mode and the main walker
stay in parity.
Added tests/unit/test_summary_encoder_phi_nonnull.cpp covering
the rename-pair address-of case. Pre-fix the test FAILs; post-fix
PASSes. 44/44 still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Apply the multi-model sampling pattern from null_deref to the five
remaining security detectors that record fuzz witnesses:
div_by_zero, int_overflow, int_truncation, sign_conversion,
oob_access.
For each, hoist the SAT-check assumptions and a local z3::solver to
the outer scope so the `recordAlternateWitnesses` call after the
primary witness can reuse whichever solver actually produced the
model — the function-portfolio `solver_` when present, otherwise
the per-query local solver.
After every `recordSinkWitnessWithModel`, enumerate up to four
alternative satisfying assignments via blocking-clause sampling
and push each as a fresh PathWitness sharing the same finding_id.
The corpus now exercises the equivalence class of values that
satisfy a path constraint instead of just the first model Z3
returned.
42/42 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the primary model is captured, enumerate up to 4 alternative
satisfying assignments. Each alternative becomes a fresh PathWitness
with the same finding_id so the prediction map stays single-row per
Finding but the corpus covers the equivalence class of values that
satisfy the path constraint.
Mechanism (param_schema.cpp::recordAlternateWitnesses):
* push() scope on the solver, accumulate blocking clauses
`OR(e_i != prev_model[e_i])` over integer/bool param exprs
* re-check with the same assumptions; SAT → record + advance prev,
UNSAT/UNKNOWN → stop early
* pop() restores the solver state so the next sink check is
unaffected
Wired into null_deref. The other security detectors (div_by_zero,
int_overflow, int_truncation, sign_conversion, oob_access) keep
single-model behavior pending a parallel refactor — their solver
plumbing varies more than null_deref's so the migration is more
invasive than a sibling-file edit.
* param_schema.{hpp,cpp} — new recordAlternateWitnesses helper +
z3::expr_vector typedef forces the model-aware variants to pull
in <z3++.h> at the boundary (forward decl impossible).
* null_deref.cpp — hoisted z3::expr_vector assumptions and the
local solver to onPointerDeref scope so the SAT branch can pass
both to the alternates loop; calls
fold::fuzz::recordAlternateWitnesses with max_alts=4.
Smoke validation: `if (len > 0 && len < 100) return *p;` produced
5 distinct `len` values (1 primary + 4 alternates: 32, 65, 96, 97,
99) plus S2's 5 boundary derivatives = 10 unique corpus seeds for
a single SAT path. Before P3 this would have been 1 + 3 canonical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For every Z3 model fold proved SAT, the concrete byte values it
picked for integer parameters often *are* the magic constants the
path guards on. Promoting them to a libFuzzer dictionary lets the
mutator splice those bytes into nearby inputs that would otherwise
fail the same guard.
Per libFuzzer's docs (https://llvm.org/docs/LibFuzzer.html#dictionaries),
dictionaries are the single largest mutation-quality lever for
formats with magic bytes, fixed headers, or length-prefixed records.
* dict_miner.{hpp,cpp} — mineDictionary(witnesses) -> dict-format
string. One entry per unique non-trivial (Integer|Bool) byte
sequence. Filters trivial values (0/1/all-FF) since S2 boundary
derivatives + libFuzzer's mutator already cover those. Encodes
printable ASCII verbatim, \xNN for non-printable.
* fuzz_prep_runner — emits one .dict per function under
<prep>/dictionaries/<safe>.dict.
* FuzzRunOptions.dict_path + runner.cpp — when set, appends
-dict=<path> to the libFuzzer argv at launch.
* fuzz_run_runner — auto-detects <prep>/dictionaries/<safe>.dict
per function and sets ropts.dict_path. No new CLI flag.
* tests/unit/test_dict_miner.cpp — 10 cases / 12 assertions
covering dedup, trivial filtering, ASCII passthrough, escape
correctness for \" and \\, pointer skip, header counts.
Smoke: leaky(int*, int) with `flag == 0xAB` guard. dict contains
"\xab\x00\x00\x00"; libFuzzer crashes in 0.7s (vs 1s without
dict — gain is modest on a 1-byte guard but the architecture is
in place for the multi-byte magic-value formats where libFuzzer's
own docs cite 5-50x speedups).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For each integer parameter in a PathWitness, derive boundary
perturbations: 0, 1, -1 (signed only), INT_MAX, INT_MIN (signed).
Classical fuzzing literature shows these catch off-by-one and
overflow bugs that random mutation needs many iterations to reach.
Derivatives share the primary witness's finding_id (so Phase 2's
crash-ingest still correlates by id equality) but are NOT added to
findings.predicted.json — adding them would bloat the prediction
map without changing the correlation key. They land in the same
corpus dir alongside the witness-derived and canonical seeds.
Content-hash filenames in corpus_writer naturally collapse
identical perturbations (e.g. when the primary already has flag=0).
* boundary_seeds.{hpp,cpp} — pure function over PathWitness POD
* fuzz_prep_runner.cpp — emit derivatives after each primary seed
* tests/unit/test_boundary_seeds.cpp — 7 cases / 39 assertions
covering 32-bit signed (5), 32-bit unsigned (3), 8-bit signed,
pointer skip, 2-param independence, bool (2), partial flag.
Smoke: leaky(int*, int) fixture grows from 4 seeds (1 witness +
3 canonical) to 9 seeds (1 witness + 5 boundaries + 3 canonical).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PathWitness gains source_file populated from the FunctionDecl's
clang::FileEntry::tryGetRealPathName() (absolute, canonical). fuzz-prep
writes it into findings.predicted.json under each prediction. fuzz-run
falls back to the recorded source_file when --target-source is omitted,
deduping per function.
End-to-end now: scan → fuzz-prep → fuzz-run, no manual file lists,
promotion still under 2s wall-clock on the null-deref fixture.
--target-source remains as an explicit override for cases where the
canonical path doesn't match a build's layout (e.g. cross-compile
sysroots, copied trees).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R3-oob: oob_access now hoists z3::model out of both solver branches
and passes it to recordSinkWitnessWithModel. Predicted seeds for
`int buf[N]; return buf[idx];` now contain Z3's chosen boundary-
violating idx (e.g. `04 00 00 00` for buf[4]) instead of zeros.
P1.7: tests/integration/test_fuzz_prep.cpp materializes a
null-deref-behind-`flag==0xAB`-guard fixture, drives runFuzzPrep
in-process, and asserts the witness seed contains the exact magic
byte (0xAB at offset 0) plus a well-formed harness (extern "C",
size-gate at total_bytes, leaky(arg0, arg1) call).
Moves fuzz_prep_runner.cpp from the fold executable target into
fold_core_sources so the integration test can link runFuzzPrep
directly.
Tests: 37 → 38 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Build the static-analysis-driven fuzzing pipeline alongside the radare2
+ z3 work. fold's symbolic engine already proves sink feasibility; this
adds the path that retains the Z3 model, lowers it to libFuzzer
harnesses + seed corpora, and writes a findings.predicted.json bridge
so a future fold fuzz-run can promote crashes to ConcreteWitness
confidence.
* fold::fuzz value types (PathWitness, ParamWitness) + WitnessRecorder
collector — process-local, lock-free hot-path check
* model_to_bytes — z3::model → little-endian std::byte at source width;
pointer null-vs-nullable disambiguation
* harness_emitter — generates .harness.cc + .schema.json; honors
is_c_linkage via `extern "C"` wrapper for C targets; nested-namespace
emission for C++
* corpus_writer — packs witness bytes per harness layout, FNV-1a hash
for filename, 3 canonical seeds (zeros / 0xFF / single byte)
* param_schema — schemaForFunction + recordSinkWitness +
recordSinkWitnessWithModel; latter looks up each parameter's z3::expr
via env->tryGet() and mines the model for byte payloads
* SafetyObserver: const SymbolicEnv* env added to onPointerDeref,
onDivision, onTruncation, onSignedUnsignedConversion,
onArraySubscript; encoder.cpp passes &env at every call site;
CompositeSafetyObserver in scan_runner fans through
* null_deref, div_by_zero, int_overflow, int_truncation,
sign_conversion now record witnesses with full model bytes;
oob_access records schema-only (sat sites don't capture a model yet)
* fold fuzz-prep subcommand — runs scan in-process (workers can't
ship recorder state), drains the recorder, emits one
{harness,schema,corpus/} bundle per function
Smoke validation: 4 detectors on a 5-line C fixture produce
non-partial witnesses whose bytes are the exact magic values that
trigger each sink (flag=0xAB for null-deref; x=0 for div-by-zero;
a+b that overflows INT_MIN for int-overflow).
Tests: 34 → 37 (add test_model_to_bytes, test_harness_emitter,
test_corpus_writer). All green.
Also folds in a small worker-toolchain optimization that had been
sitting in the working tree: preloadHostToolchainEnv() avoids
repeated popen calls during worker init.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>