Skip to content

Crucible prototype#64

Draft
ericeil wants to merge 128 commits into
masterfrom
eric/crucible
Draft

Crucible prototype#64
ericeil wants to merge 128 commits into
masterfrom
eric/crucible

Conversation

@ericeil

@ericeil ericeil commented Jul 10, 2026

Copy link
Copy Markdown

No description provided.

jtoman and others added 30 commits June 25, 2026 09:40
Implement the framework that lets AutoProver formalization backends and whole
applications be written in Rust and driven by the generic Python pipeline, per
docs/rust-formalization-backends.md and docs/rust-applications.md.

Rust (rust/):
- autoprover-sdk: the crate new apps import — the JSON ABI (descriptor,
  Command/Observation IoC protocol, results, verdicts), the Application /
  FormalizeSession traits, sync FFI helpers, and the export_app! macro that
  emits the PyO3 module.
- example-app: the `echoprover` demo application, built into a wheel via
  maturin; exercises every Command variant.
- Cargo workspace + maturin build infra (abi3-py312, extension-module).

Python (composer/rustapp/):
- The inversion-of-control effect loop: Python owns the async event loop and
  every effect (LLM, prover, cache, event streaming); Rust only decides the
  next one. No pyo3-async bridge.
- Adapter implementing the real PipelineBackend / PreparedSystem / Formalizer
  protocols over a Rust wheel, plus RealEffects (LangGraph stream writer,
  model, injectable prover/feedback hooks).
- Descriptor models, cacheable FormT (RustFormalResult), artifact store,
  phase-enum synthesis, run_rust_pipeline, build_application.

Also widen ReportBackend to `| str` so a Rust app can stamp its own report tag.

Tested end-to-end (tests/test_rustapp.py, 6 passing) driving the real echoprover
wheel through the loop with a fake effect handler: publish, give-up, and
cache-hit paths, plus descriptor/core-phase synthesis and result round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the Rust application vertical: a Rust wheel now becomes a runnable
application with no bespoke Python, everything synthesized from the descriptor.

composer/rustapp/entry.py — descriptor-driven async entry point (rust_entry_point):
argparse built from the descriptor's declared args + standard flags, precondition
validation delegated to the Rust validate_preconditions hook, a neutral RAG-free
env (build_neutral_env, overridable), and build_arg_parser for introspection.
Service wiring (Postgres pools, thread logger, WorkflowContext) mirrors the
foundry entry point.

composer/rustapp/frontend.py — GenericRustApp (Textual MultiJobApp),
GenericRustTaskHandler, and GenericRustConsoleHandler, all data-driven by the
descriptor's event_kinds: a Rust Command::Emit becomes a custom-stream payload the
handler renders if its type is declared. No per-app subclass needed.

composer/rustapp/cli.py — tui_main(module) / console_main(module); an app's CLI is
two lines. Imports composer.bind first (DI/tape bootstrap), like the built-in mains.

host.py — run_application builds the backend from a pre-synthesized RustApplication
so the frontend's phase_labels and the backend's core_phases share ONE enum object
(the identity the frontend's label lookup relies on); prevents silent label misses.

Tests (10 passing): descriptor-driven argparse (declared-flag defaults + override),
the shared-enum identity invariant, console-handler event rendering (declared shown,
undeclared ignored), and Textual app construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add docs/ecosystem-abstraction.md proposing a second pipeline axis — the
ecosystem (blockchain/source domain) — orthogonal to the backend axis, so the
shared front half (system model, analysis + property-extraction prompts, source
conventions, connectivity validation) stops being silently hardwired to Solidity.

Factors an ecosystem into a language facet (solidity, rust) and a chain facet
(evm, solana, soroban), composed via Jinja prompt fragments. The rust language
facet — Cargo fs conventions, the code_explorer prompt, and the rust failure-mode
fragment (overflow, panics) — is authored once and shared by both Solana and
Soroban; only each chain's model and platform failure modes differ. Notes that the
sharing is not strictly hierarchical (Soroban's contract-owns-typed-storage model
is closer to EVM's than to Solana's account model), motivating composable fragments
over rigid inheritance.

Includes the Solidity-coupling audit, the Language/Chain seam, ecosystem selection
via an application parameter (built-in apps pass EVM; the rustapp AppDescriptor
gains an ecosystem: ChainTag field), the behavior-preserving EVM extraction, the
Solana and Soroban chain sketches, a phased plan, open questions, and key files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Behavior-preserving refactor introducing the ecosystem seam and capturing today's
behavior as EVM = SOLIDITY ⊕ evm (see docs/ecosystem-abstraction.md §10 phase 1).
The shared front half (system analysis + property extraction) stops being silently
hardwired to Solidity; the driver defaults to EVM, so existing apps are unchanged.

New composer/pipeline/ecosystem.py:
- PromptPair, Language (source-level facet), Ecosystem (chain facet) dataclasses.
- main_instance moved here (it is EVM's locate_main); re-exported from
  composer.pipeline.core so foundry/prover/rustapp importers are unaffected.
- SOLIDITY + EVM instances wiring the existing SourceApplication, prompt template
  names, _validate_connectivity, and unit enumeration — a move, not a rewrite —
  plus an ECOSYSTEMS registry ({"evm": EVM}).

Threaded through, all with behavior-preserving defaults:
- run_component_analysis: keyword-only system_template / initial_template / validate.
- run_property_inference: system_template / initial_template, threaded down through
  _run_bug_analysis_inner -> _run_bug_round -> _get_initial_prompt.
- run_pipeline: takes ecosystem (default EVM) and drives analysis/extraction from it
  (system_model, prompts, validate, analysis_extra_input, units). The one EVM
  assumption kept for now — prepare_system(analyzed: SourceApplication) — is bridged
  with a documented cast, removed by phase 2's App type parameter.

Verified: EVM reproduces the prior template names, validate identity, and the
verbatim analysis front-matter; no import cycle; 115 passed / 4 skipped (the 2
tree-parsing failures and rag_db errors are pre-existing env issues — missing
certoraRun CLI and DB containers — unrelated to this change). The doc's golden-run
gate needs Docker/Postgres/LLM and should be run in CI before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… param

Static-typing generalization (no runtime behavior change): make the analyzed
application model a first-class type parameter so the backend and the ecosystem
that produces its model are paired by type, and the Phase 1 cast disappears.

- Ecosystem is now generic over App (composer/pipeline/ecosystem.py): system_model:
  type[App], locate_main: Callable[[App, ...]]; EVM: Ecosystem[SourceApplication];
  registry typed dict[ChainTag, Ecosystem[Any]]. validate_analysis stays typed over
  BaseApplication (it narrows the produced model internally, and this keeps it
  assignable to run_component_analysis's validate parameter).
- PipelineBackend gains the App type parameter; prepare_system(analyzed: App).
- run_pipeline is [P, FormT, H, A, App] with ecosystem: Ecosystem[App] as an explicit
  argument; `analyzed` flows as App straight into prepare_system — the Phase 1
  cast(SourceApplication, analyzed) and the unused `cast` import are removed.
- The four callers (prover, foundry, both rustapp entries) pass ecosystem=EVM.
- Also fix a pre-existing pyright nit in build_phase_enum (functional enum.Enum
  → type[Enum]).

SystemAnalysisSpec is intentionally NOT parameterized — it carries only
analysis_key + extra_input, no App-typed member (the analyzed type lives on the
ecosystem).

Verified: pyright reports 0 errors on the touched files (the pairing type-checks
with no cast; one pre-existing _batch_cache_key TypeVar warning remains). Compiles,
imports cleanly, unit tests green. No end-to-end gate needed — PEP 695 generics /
Protocol are erased at runtime and removing a cast is a no-op, so the Phase 1 gate
result stands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…criptor

Wire ecosystem selection into the Rust application framework, so a Rust wheel
declares which ecosystem (chain) its backend targets and the host routes the
shared front half accordingly — replacing the hardcoded EVM in rustapp.

- Rust SDK (rust/autoprover-sdk): AppDescriptor gains `ecosystem: String`
  (serde default "evm", so descriptors built before the field still deserialize);
  echoprover sets ecosystem="evm".
- Python mirror (composer/rustapp/descriptor.py): AppDescriptor.ecosystem:
  ChainTag = "evm" (ChainTag defined locally to keep the ABI-mirror decoupled
  from the pipeline).
- Host (composer/rustapp/host.py): new resolve_ecosystem(descriptor) does the
  ECOSYSTEMS registry lookup with a clear error for an unregistered chain;
  threaded through build_application (stored on RustApplication.ecosystem),
  run_application, and run_rust_pipeline. Exported from the package.
- Tests: descriptor carries + resolves ecosystem to EVM; build_application carries
  the resolved ecosystem; an unregistered chain ("solana") raises; an absent field
  defaults to "evm".

Only `evm` resolves today (Solana/Soroban register in phases 4-5), so behavior is
unchanged; the plumbing is now in place. Also marks phases 1-3 done in
docs/ecosystem-abstraction.md and records the solc-provisioning finding from the
phase-1 gate run.

Verified: cargo build clean, pyright 0 errors on touched Python, 14 rustapp tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…del/ecosystem

Foundation for the Solana chain: generalize the shared driver over ecosystem-provided
Unit/Main types, add the standalone Solana system model, and register the RUST language
facet + SOLANA chain. EVM behavior is preserved (still bound to
ContractComponentInstance/ContractInstance). Solana prompts, a sample Anchor scenario,
and the live-LLM gate follow in the next commit.

Driver generalization (composer/pipeline/core.py, system_model.py, prop_inference.py):
- FeatureUnit protocol (in system_model) captures the per-unit interface the driver needs
  (display_name / slug / unit_index / cache_material / context_tag). ContractComponentInstance
  implements it with byte-identical cache keys/tags, so EVM behavior is unchanged.
- Thread Unit/Main type params through Ecosystem, PreparedSystem, PipelineBackend, Formalizer,
  BackendJob/ComponentOutcome/_Batch/CorePipelineResult, run_pipeline, _extract_all, and
  run_property_inference. The driver now uses the protocol members instead of ContractComponent
  fields.
- Relax BaseApplication's type bound from SystemComponent to BaseModel so non-EVM component
  unions fit.

Solana model (composer/spec/solana/model.py): SolanaApplication (programs + authorities),
SolanaProgram / SolanaInstruction / AccountConstraint (roles + program-enforced checks) /
CpiCall — accounts-passed-in, signers, PDAs, CPIs, native fields. SolanaProgramInstance /
SolanaInstructionInstance are the Main/Unit; the instruction instance satisfies FeatureUnit.

Ecosystem (composer/pipeline/ecosystem.py): Ecosystem[App, Main, Unit]; RUST language (Cargo
forbidden_read, Rust/Solana code_explorer prompt); SOLANA chain (validate/locate_main/units,
solana/*.j2 prompt names); ECOSYSTEMS = {"evm", "solana"}. Backend annotations updated to the
new param counts; a pre-existing llm_factory(args) typing nit in the rust entry cleaned up.

Verified: pyright 0 errors on all touched files (one pre-existing _batch_cache_key warning);
84 unit tests pass; a Rust wheel with ecosystem="solana" resolves end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the Solana chain: the prompt fragments/templates, a reusable null Solana backend,
a sample Anchor scenario, and the live-LLM front-half gate.

Prompts (composer/templates), with the fragment-composition convention introduced:
- rust/_failure_modes.j2 — the SHARED Rust language failure-mode fragment (overflow-panic DoS,
  unwrap/panic aborts, truncating casts, unchecked results); will be reused by Soroban (phase 5).
- solana/_failure_modes.j2 — Solana platform failure modes (missing signer/owner, account
  substitution/confused-deputy, unvalidated PDA/bump, arbitrary CPI, lamport/rent & close bugs,
  duplicate mutable accounts, reinit, sysvar spoofing).
- solana/{analysis_system,analysis_prompt,property_system,property_prompt,instruction_context}.j2
  — Solana analysis (produces SolanaApplication) + per-instruction property extraction; the
  property prompt {% include %}s the shared rust/ + solana/ failure-mode fragments.

Null backend (composer/spec/solana/null_backend.py): NullSolanaBackend over the Solana
(App, Main, Unit) triple — records extracted properties without verifying (the gate's
null/echo backend, and the reference a real Solana verifier is modeled on).

Scenario (test_scenarios/solana_vault): a small Anchor lamports-vault program + design doc.

Gate (tests/test_solana_gate.py, expensive): runs real analysis + per-instruction extraction
through SOLANA + the null backend on the vault (Postgres via testcontainers, no prover/solc).

Gate result: PASSED (3 instructions, 27 properties) with sane Solana properties — signer/owner
checks, PDA/bump canonicity, System-Program substitution, reinit-once, arithmetic overflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update §10: phase 4 done — driver Unit/Main generalization (deferred from phase 2),
standalone SolanaApplication model, RUST language + SOLANA chain, the {% include %}
fragment convention (a base template proved unnecessary), a null Solana backend, and
the Anchor vault scenario. Records the live-gate result (3 instructions, 27 sane
properties). Status line now reads phases 1–4 done; 5 (Soroban) and 6 (backends) remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds docs/crucible-application.md: a plan to pair the existing `solana`
ecosystem front half with a new Crucible (Solana fuzzer) backend, built as a
Rust application on the PyO3 framework.

Key decisions captured:
- Crucible is the Solana analog of Foundry (authors a source-language artifact,
  gates it with a local CLI, refutation-oriented verdicts).
- A general RunCommand effect (Rust decides argv, LLM authors only file
  contents) replaces the prover-specific IoC vocabulary.
- Sandboxing every RunCommand is a required, definition-of-done phase (bwrap on
  Linux; macOS dev mechanism TBD), because the LLM-authored harness runs as
  native code (verified against Crucible source: cargo build + Command::new,
  LiteSVM only sandboxes the program under test, no isolation in-tree).
- Version compatibility (Crucible/Solana-Anchor/Rust) and a shared Solana build
  pipeline reused across backends (Crucible no-munge; a future Prover backend
  munge-and-rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds §7.5: how the harness author gets Crucible documentation, designed for
the large-corpus future (a Certora Prover/CVLR Solana backend) rather than
Crucible's small doc set.

- Build tool-enabled call_llm now as a shared rustapp capability (host-assembled
  tool belt: backend RAG search over the descriptor's ComposerRAGDB + learned-KB
  + source tools), so CVLR-Solana reuses it with zero framework change. Fixes the
  gap that today's IoC call_llm is a tool-less single ainvoke.
- Knowledge rides the backend axis (per-wheel rag_db_default), not the ecosystem
  axis; static injection of a harness cheat-sheet is a Crucible content shortcut
  layered on top.
- Wire the knowledge seam into Phase 3; add open question on one-DB-vs-multiple
  for CVLR; add key-files rows for the RAG precedent + new crucible_kb builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A backend-agnostic "run a local command over a set of files" effect, replacing
the prover-specific shape for CLI-gated backends (Crucible, cargo build-sbf,
anchor idl). The Rust decider authors program+args; only file contents may be
LLM-derived.

- SDK: Command::RunCommand { program, args, files } + Observation::CommandResult.
- composer/rustapp/command.py: run_local_command — the single command choke point
  (write files into a confined workdir, exec-not-shell, timeout, optional
  semaphore, capture). This is what phase-6 sandboxing (docs §7.4) will wrap.
- loop.py / adapter.py: Effects.run_command, drive_session branch, and
  RealEffects.run_command with a lazily-created per-formalize workdir.
- tests/test_rustapp_command.py: round-trip, path confinement, no-shell-injection,
  missing binary, non-zero exit, timeout.

No sandbox yet (network-off/clean-env/resource-caps is phase 6); run only on
trusted input for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RustBackend adapter was hardwired to the EVM types (SourceApplication /
ContractInstance / ContractComponentInstance / main_instance), so a non-EVM
(e.g. solana) wheel could not run. Generalize it over the resolved ecosystem:

- FeatureUnit gains feature_json() — the generic way to marshal a unit's
  semantic content across the FFI. EVM returns the component model_dump
  (byte-identical to before); Solana returns {program, instruction}.
- RustBackend holds the resolved ecosystem and uses ecosystem.locate_main
  instead of main_instance; prepare_system/formalize/to_artifact_id/finalize
  now work through FeatureUnit (feature_json / slug / display_name) rather than
  EVM-specific attrs.
- host.build_backend / build_application thread the ecosystem in.

EVM path preserved (echoprover + rustapp tests green, pyright clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A new Solana verification application (ecosystem="solana") backed by the Crucible
fuzzer. Phase 1 provides the declarative descriptor + a real validate_preconditions;
the authoring loop is a deliberate stub (phase 1 covers preconditions + build/IDL +
dry-run infra, no LLM).

- rust/crucible-app: descriptor (phases incl. a UI-only Build Harness phase; args
  --crucible-version/--fuzz-timeout/--fuzz-cores/--stateful; rag_db_default
  "crucible_kb"; fuzz-flavored event kinds; provisional artifact layout) +
  validate_preconditions (crucible/cargo-build-sbf/anchor on PATH via a pure PATH
  scan, plus a buildable Cargo workspace check) + refutation-oriented
  backend_guidance.
- Added to the rust workspace; maturin added as a dev dependency to build the wheel.

Verified: cargo check clean; `maturin develop` builds+installs the wheel; the host
loads the descriptor, resolves ecosystem -> solana/rust, and validate_preconditions
reports the missing workspace correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes Crucible phase 1: the build + dry-run infrastructure, gated end to end.

- composer/spec/solana/build.py: build_program — the shared Solana build capability
  (source -> target/deploy/<program>.so [+ optional IDL]) routed through the same
  run_local_command choke point the RunCommand effect uses. Crucible calls it in
  no-munge mode; a future Prover/CVLR backend calls it in munge-and-rebuild mode.
- test_scenarios/solana_vault: made a buildable Cargo workspace (Cargo.toml,
  programs/vault/Cargo.toml, rust-toolchain.toml) and fixed the program so it
  compiles against anchor-lang 1.0.1 (valid declare_id, invoke-based system
  transfer, renamed the #[program] module to vault_program to avoid a crate/module
  name clash in the harness). Build outputs + the generated fuzz harness are
  gitignored.
- tests/test_crucible_gate.py (expensive): loads the descriptor (ecosystem ->
  solana), runs validate_preconditions, builds vault.so, materializes a trivial
  hand-written fuzz harness (crate deps resolved from CRUCIBLE_REPO), and asserts
  `crucible run vault invariant_vault --dry-run` exits 0. No LLM, no authoring.

Gate verified green locally (cargo-build-sbf + crucible on PATH,
CRUCIBLE_REPO=~/src/crucible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements §7.1: a Crucible deliverable is one Cargo crate (single [[bin]]
invariant_test) assembled from a shared fixture + one test fn per component, not
one file per component. Phase 1 revealed crucible's CLI hardcodes the bin name, so
per-component bins are a dead end; components are selected by a Cargo feature whose
name equals the test fn (Crucible's #[invariant_test] macro self-gates main() by
#[cfg(feature = "<fn name>")]).

- composer/crucible/harness.py: CrucibleHarness assembler (renders Cargo.toml
  feature list + src/main.rs = shared fixture + verbatim per-component test fns) and
  CrucibleDep (resolves crucible/solana/anchor deps from a local checkout, §6.1).
- composer/crucible/store.py: CrucibleArtifactStore — per-component write_artifact
  folds the test fn into the crate and re-renders it under fuzz/<program>/, while the
  shared base writes metadata under certora/crucible/ (the same split Foundry uses).
- tests/test_crucible_gate.py: phase-2 gate — a hand-authored fixture + one test
  written through the store assembles a crate that `crucible run vault c_deposit
  --dry-run` accepts; metadata lands under certora/crucible/ and a co-located EVM
  certora/specs/ deliverable is untouched. Gate verified green.
- docs §7.1 corrected to the verified macro-self-gating mechanism (the earlier
  #[cfg]-wrapped-section description was wrong; the gate caught it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RealEffects.call_llm now runs a bounded, tool-enabled agent turn (env.all_tools:
source navigation + RAG search + learned-KB, plus a result tool) via
run_to_completion, instead of a bare single ainvoke. This is the shared framework
change §7.5 calls for: the harness author can pull in framework docs / read the
program mid-turn, and a large-corpus backend (CVLR-Solana) reuses it by shipping
only a knowledge DB — no framework change.

echoprover unit tests use a fake Effects (not RealEffects) so are unaffected; real
validation is the phase-3 authoring gate. (The pyright NotRequired/MessagesState
warning on the new state class is the pre-existing langgraph-stub false positive
that also affects composer/spec/code_explorer.py.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ericeil and others added 30 commits July 14, 2026 14:09
The fuzz budget now travels as a descriptor-declared arg injected into the
component context (declared_args -> AuthorInput.context['fuzz_timeout']), so the
dedicated fuzz_timeout_s field/param on BackendOptions, RustBackend, RustFormalizer,
and build_application was dead. Removed. Backends read the value via the context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stinction explicit

Audit confirmed the two axes are already cleanly separated in code — the analyzed
program's language rides on the ecosystem (Ecosystem.language), and the entry point
derives forbidden_read from app.ecosystem.language, never from the fact that the
backend is a Rust wheel; no implementation-language notion is attached to the
ecosystem or descriptor. echoprover (a Rust-implemented backend analyzing
Solidity/EVM) proves the independence.

This only tightens the prose so a reader can't conflate them:
- Ecosystem.Language docstring + module docstring: state the facet is the language
  of the *code under analysis*, explicitly not the backend's implementation language.
- composer/rustapp docstring: state that "Rust" there is the *backend implementation*
  language, orthogonal to the ecosystem, and that the host reads the analyzed-source
  language from app.ecosystem.language rather than assuming it.

No behavioral change (docstrings only); pyright clean, imports OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…FeatureUnit)

The shared core hardcoded ContractComponentInstance as a stand-in for "the unit",
so the ecosystem-generic driver had to cast every non-EVM unit to the EVM type —
a cast that was actually a lie for Solana (a SolanaInvariantUnit is not a
ContractComponentInstance; it only worked because the generic path touches no
EVM-specific members).

Parametrize BackendJob / ComponentOutcome / _Batch / Formalizer / PreparedSystem /
PipelineBackend over `U: FeatureUnit` and let each backend declare its concrete unit:
CVL & Foundry → ContractComponentInstance (their finalize now reads .slugified_name /
.component cast-free), Rust & null-Solana → FeatureUnit (null's to_artifact_id widened
from the too-narrow SolanaInstructionInstance).

CorePipelineResult stays mono in the unit (it only reads .display_name). The ecosystem
is invariant in its Unit typevar and callers pass a concrete chain, so it can't be tied
to the backend's U at the signature; extraction therefore yields FeatureUnit batches and
run_pipeline reunites them with the backend's U via one honest widening cast. Net: two
clearly-scoped boundary casts (extraction→U, outcomes→FeatureUnit for the rollup) replace
the misleading per-unit ContractComponentInstance casts, and the whole core is now
honestly typed.

pyright clean across all 19 pipeline-type consumers; 246 non-expensive tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_all

The two strategies are duals — global extraction infers once over the whole
program and fans each property into its own unit (one prop per batch), while
per-component infers per unit (concurrently) and keeps that unit's properties
grouped — but they shared context-building, inference, and batch construction
with the code duplicated across both branches.

Factor the shared machinery into `_unit_ctx` (derive a unit's context) and
`_extract` (run inference), and reduce both strategies to a list of
(unit, properties, context) triples materialized by one shared `_Batch`
comprehension. The branch now expresses only the genuine difference (fan
direction); no behavior change (per-component still reuses its extraction
context — no redundant `.child`; global still builds a fresh per-property
context). pyright clean; 246 non-expensive tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-contained command-sandbox mechanism, extracted from eric/crucible as the
first of the 4 stacked PRs (docs/pr-split-plan.md). Both sides ship together so
a parallel Python project can adopt it immediately:

  from composer.sandbox import run_local_command, rust_build_policy, ...

Python (composer/sandbox/, stdlib-only, no composer deps outside itself):
  - policy.py   tool-agnostic seam: SandboxPolicy / SandboxProvider / LaunchSpec,
                the `none` passthrough, the provider registry, fail-closed helpers
  - command.py  run_local_command — the RunCommand primitive (materialize input
                files into a workdir, run a command there); the LLM controls only
                file contents, never the command line
  - launcher.py the `launcher` provider: maps a policy to a run-confined invocation
                ($RUN_CONFINED_BIN -> PATH -> repo build dir)
  - recipes.py  rust_build_policy + DEFAULT_ENV_PASSTHROUGH (the shared "compile/run
                Rust" confinement recipe, usable by Rust and future Python backends)
  - config.py   SandboxConfig

Rust (rust/run-confined): the trusted launcher — Landlock filesystem + seccomp
network/ptrace + rlimits + scrubbed env, then execve. Workspace root trimmed to
members = ["run-confined"] (the pyo3 framework crates rejoin in later PRs);
Cargo.lock regenerated for that subset.

Packaging: scripts/Dockerfile builds run-confined into the image on PATH;
.dockerignore + .gitignore exclude the Rust build output.

Docs: docs/command-sandbox.md.

Gate: tests/test_sandbox{,_command,_config,_launcher,_run_confined,_escape}.py
— 42 passed (escape gate: all vectors denied + unconfined control; run-confined
confinement exercised, not skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the raw landlock_create_ruleset syscall in --probe with the same
BestEffort ruleset negotiation apply_landlock uses, inspecting RulesetStatus.
Drops the unsafe block and the hand-rolled LANDLOCK_CREATE_RULESET_VERSION
constant; the crate deliberately hides the numeric ABI, so probe now reports
the enforcement status instead. Python's available() only checks the exit
code and stderr, so the stdout change is safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The launcher's --probe no longer reports the numeric Landlock ABI (the crate
hides it); it builds a best-effort ruleset and reports whether Landlock
actually enforces. Update §7 and §9 step 2 to match, following the switch of
probe() to the crate's public API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…up apps`

Previously the crucible_app / echoprover PyO3 wheels were installed out-of-band
(`maturin develop`), so every `uv sync` pruned them as extraneous and you had to
rebuild by hand each time.

Declare them as editable path dependencies in a dedicated `apps` group so
`uv sync --group apps` builds them via maturin AND preserves them (no more
pruning). The group sits outside the default groups, so the container image
(final stage has no Rust toolchain; syncs with UV_NO_DEV=1 --group ragbuild)
never tries to compile them — verified the image's sync line excludes the apps
packages, and `uv lock --check` stays green for its --frozen build.

Also add `maturin-import-hook` to the group: after a one-time
`python -m maturin_import_hook site install`, editing a crate transparently
recompiles it on import (with the venv activated so maturin is on PATH), so the
manual `maturin develop` step is gone entirely.

docs/crucible-demo.md updated: the "uv sync prunes the wheels" gotcha and the
manual rebuild steps are replaced with the --group apps + import-hook flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the Crucible backend a `judge_prompt` (peer of Foundry's feedback judge),
so an authored test suite is reviewed for *meaningfulness* before it counts —
not just that it compiles. Modeled on Foundry's 8-criteria prompt but retargeted
to LiteSVM fuzzing: the load-bearing axis is reachability (can the fuzzer, via the
fixture's `action_*` methods, drive a state where the invariant could fail?).

SDK:
  - `FailureKind` (Compile | Judge) on `Failure`, serde-defaulted to Compile, so a
    re-author can tell a build failure from a judge rejection.

crucible-app (the wheel):
  - `judge_prompt` for `kind="component"` (skipped for the shared fixture): a
    reviewer persona + criteria, emitting the `{accept, feedback}` JSON the host's
    `_parse_judge` consumes; a rejection re-authors with the feedback framed as
    review notes, NOT compiler errors (`judge_revise_suffix` vs `revise_suffix`,
    dispatched on `FailureKind`).
  - `judge` event kind so the verdict surfaces in the TUI/console.

adapter (the host):
  - tag judge rejections `kind="judge"`; label the judge's LLM turn distinctly
    ("<app> judge turn", was mislabeled "authoring turn"); emit the verdict.

Two authoring fixes found via the e2e run (solana_vault, real model):
  - TEST_CHEAT_SHEET: the real raw-lamports API (`read_account`/`get_account`
    return `Result<Account>` — unwrap first; there is NO `get_account_lamports`),
    which the model kept mis-guessing and burning re-authors on; plus a
    transaction-fee caveat (don't assert an exact lamport delta on a signer/fee
    payer — the ~5000-lamport fee makes it a false oracle).
  - the judge's Criterion 7 gets the same real-execution-costs principle, to catch
    that false-oracle class if the author writes it anyway.

Verified: `test_crucible_e2e_gate` passes with the judge live (13/13 delivered,
judge fired + reasoned per component, reject/re-author path exercised, 0 give-ups);
unit tests in test_crucible_events cover the judge prompt, the setup skip, and the
judge-vs-compile revise framing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vability)

A refuted property surfaced as a bare `BAD` with no clue why — `validate`
classified a `[FUZZ_FINDING]` and discarded Crucible's `summary:<msg>` (the
failed `fuzz_assert_*` message with the actual vs expected values). Capture it
and make it durable.

- SDK: `Verdict.detail` — optional explanation of a non-GOOD outcome.
- crucible-app `validate`: `finding_detail()` parses `[FUZZ_FINDING] crash:<id>
  … summary:<msg>` into the BAD verdict; the ERROR paths carry their error text in
  `detail` too (and stop overloading `unit_file`, which is a report identity key).
- report: thread it through as `Verdict.message` → `RuleVerdict.message`, so it
  persists in report.json; render it on the rule row (`.finding`) in the HTML.
  Additive and defaulted to None — prover/foundry are unchanged.
- adapter: `fetch_verdicts` maps `detail → message`; the live `verdict` event line
  appends the detail.

Now the `deposit_increases_balance_exactly` BAD from the e2e would read, in
report.json and the rendered report, "crash <id>: deposit(...) must decrease
depositor lamports by exactly that amount: expected … but got …" instead of a
bare BAD.

Tests: verdict `detail → report message` threading (test_crucible_events) and the
finding message rendering on the row (test_autoprove_report); report suite green
(prover/foundry unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… turn

The judge was a stateless host-driven turn fired after every author attempt,
which re-explored the program from scratch and ~5×'d the e2e (docs/crucible-judge-cost.md).
Move it into the author loop, matching CVL/Foundry's `feedback_tool` shape
(docs/crucible-judge-in-loop.md): the author calls a `request_review` tool, self-
revises against the feedback in one session, and a `bind_standard` gate blocks
`result` until it submits a judge-accepted draft. Author and judge share the run
memory, so facts persist across components instead of being re-derived.

Purely host-side — the wheel API is unchanged. The judge runs in-loop whenever the
wheel supplies one, detected by probing the pure `judge_prompt` callout (it returns
None exactly when there is no judge for the input); no descriptor flag. The separate
`_judge_turn` host branch is gone (still used inside the in-loop hook and the
judgeless setup path). `echoprover` (no judge) keeps the single-shot author.

e2e (solana_vault, real model) vs the host-driven run: 1:33:20 (was 1:53:04);
13/13 GOOD (was 12 GOOD + 1 BAD — the fee-oracle test now correct, BAD→GOOD, with
the judge observed reasoning about the LiteSVM fee payer); author turns 23→14;
build-fail re-authors 3→0 (the earlier get_account_lamports cheat-sheet fix). The
review sub-agent still explores, so the wall-clock win is modest — a follow-up
clamps its tool belt/recursion for the rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-loop review sub-agent still re-explored the program (17 nested `code_explorer`
calls in the last e2e), which dominated its cost — each `code_explorer` is itself a
multi-call sub-agent. It doesn't need it: the judge prompt already carries the program
API + fixture and the review shares the run memory, so direct `get_file`/`grep`
spot-checks suffice. `run_llm_agent` gains an `exclude_tools` filter; `_judge_turn`
drops `code_explorer` (`_JUDGE_EXCLUDE_TOOLS`). The author keeps the full belt.

Phase 1 of docs/crucible-judge-cost.md (tool clamp + memory continuity); the
recursion cap and cadence tweaks (Phase 2) remain open. Behavior/verdict impact to be
confirmed on the next e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Crucible generated one fuzz harness per invariant and ran each separately (N
builds + N campaigns, serialized). The fuzz *engine* was already whole-program
(one shared fixture, explore mode) — only the authored test, run, and verdict
were sharded (docs/crucible-unit-granularity.md §2). Collapse them: one harness
fn (`c_invariants`) asserting every invariant, one build, one fuzz run.

Driver: `Ecosystem.collapse_units` (set on SOLANA) keeps all invariants in ONE
batch on the whole-program unit (`_extract_all`), so `formalize` is called once
with every property.

Wheel: crucible-app authors a single `c_invariants` fn (each assertion tagged
`[<title>]`); `units()` returns a report row per property (`c_<slug>`) that all
share one fuzz `target` (SDK `Unit.target`, new).

Attribution stays per-property AND lives in the backend (not the host — see the
translation-layer cleanup below): `validate` runs the shared target once and
returns a `Verdict` per covered unit (`ValidateOutcome::Verdicts`), pinning a
counterexample to the property its finding names and holding the rest GOOD.

Translation-layer cleanup of composer.rustapp: the host no longer computes
verdicts or parses a backend's finding format — it runs each declared `target`
once and records the backend's per-unit verdicts verbatim (dropped
`_attribute_run`). Also generalized comments/docstrings in the generic host + SDK
so they don't assume fuzzing (Crucible stays a labeled example).

Measured: the whole vault (13 invariants) e2e ran in 0:59:22 — one build + one
campaign vs 13 — down from 1:33:20 (per-invariant in-loop) and 1:53:04 (original).
NOTE: that run predates the per-unit-verdict `validate` contract change; the new
contract is unit-tested (test_crucible_events / test_crucible_granularity) but not
yet re-exercised end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…importer

Replace the Crucible-specific RAG builder with a common JSON manifest format
and one corpus-agnostic importer, and ship Crucible's corpus as a checked-in
artifact so no crucible checkout is needed at build or run time.

- composer/rag/import_format.py: the manifest models (RagManifest/Section/Block),
  free of any RAG-stack imports so a producer needs only these to emit a corpus.
- composer/scripts/rag_import.py: the generic importer. Loads/validates a manifest,
  resolves knowledge_base -> connection (composer/rag/db.py KNOWLEDGE_BASES,
  overridable by --output), and always feeds both indexes (vector via
  add_chunks_batch + manual via add_manual_section) with shared BlockBuilder
  chunking and per-output part-numbering.
- rust/crucible-app/crucible_kb.rag.json: the Crucible corpus, generated once from
  the crucible repo docs/ (126 sections), committed alongside the app.
- Container/demo import the committed manifest: the Dockerfile copies it to
  $AUTOPROVE_HOME and autoprove-entrypoint.sh's setup-db runs rag_import into the
  crucible_rag schema (alongside the CVL rag_db build); crucible-demo.md 1g imports
  the same file. Previously the crucible RAG was never built in the container.
- Remove the Crucible-specific scripts (crucible_ragbuild.py, populate_crucible_rag.sh);
  composer ships only the generic importer + schema. Regeneration recipe recorded in
  docs/rag-import-format.md.

Foundry and CVL builders are untouched. No runtime code changes: search tools,
rag_env.py, and the DB API are unchanged — this only changes how the Crucible
corpus gets in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the large inline string consts and `format!` literals in crucible-app's
lib.rs with askama templates under `templates/*.j2` — the same `.j2` convention
used by composer/templates/*.j2, here on the Rust side. Templates are checked at
build time and embedded in the wheel (no runtime loading or packaging).

- 15 templates: the static prose (backend guidance, cheat-sheets, worked example,
  judge system/guidance, probe fn), the prompt-assembly (author setup/component,
  judge instruction, the two revise suffixes), the crate files (Cargo.toml + deps),
  and the api-facts block (rendered from a prepared struct via a {% for %} loop).
- lib.rs: string consts / format! bodies -> `#[derive(Template)]` structs + render;
  JSON-shaping logic (api_facts extraction) stays in Rust and feeds the template.
  `escape = "none"` (prompts/Rust/TOML, not HTML); askama.toml pins whitespace.
- LLM-prompt prose is line-wrapped to 120 with a tag-aware wrapper (never splits a
  {{…}}/{%…%} tag). The code-bearing cheat-sheets/example, the api-facts listing,
  and the byte-exact crate files are left with their own line structure.

Verified: the crate files (Cargo.toml/deps) render BYTE-IDENTICAL to the former
format! output (parity test keeps the old code as the oracle); static templates
preserve their bytes; author/judge/revise prompts render end-to-end with no
template residue. cargo test + clippy clean; a fresh maturin wheel renders the
wrapped prompts; the Python crucible tests pass; run-confined still builds.

Note: maturin-import-hook watches the crate's Rust sources, not templates/, so after
editing a .j2 run `maturin develop` (or touch src/lib.rs) to force a rebuild; any
real cargo/maturin/CI build reads the current templates (askama rerun-if-changed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deny io_uring and non-AF_UNIX sockets in seccomp, install Landlock
scopes (signals/abstract UDS) and TCP default-deny, grant only shared
cargo bin/ (not credentials.toml), and extend the escape suite.
The seccomp deny-list keyed on exact x86_64 syscall numbers was bypassable
via the x32 ABI: an x32 call runs under the same AUDIT_ARCH_X86_64 identity
(so seccompiler's arch guard passes it), but its syscall number is OR'd with
__X32_SYSCALL_BIT (0x4000_0000), so it misses every exact-number rule and hits
default-allow — a full bypass of the socket/io_uring/ptrace/process_vm denies.
This matters most on kernels < 6.7 (the AL2023 6.1 target), where Landlock does
no network filtering and seccomp is the sole network control, so the bypass is
unconditional egress to the network + IMDS.

Fix: mirror every deny onto its x32-tagged syscall number in apply_seccomp
(x86_64 only; aarch64 has no per-syscall compat bit). Deriving the mirror from
the already-built rule map means any future deny is covered automatically.

Regression test records the errno of an x32 socket() attempt and asserts EPERM
(seccomp caught it) rather than ENOSYS (kernel rejected it after the filter let
it through) — a real guard even on x32-disabled CI kernels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Landlock + seccomp are kernel-mediated and a container shares the host kernel,
so ubuntu-latest cannot validate run-confined on the 6.1 kernel we ship on.
This job boots the real Amazon Linux 2023 kernel-6.1 KVM cloud image under
QEMU/KVM (SHA256-verified, UEFI/OVMF, cloud-init SSH seed) and runs
tests/test_sandbox_escape.py inside it.

On 6.1 the suite exercises the 6.1 contract specifically: Landlock FS enforced,
network seccomp-only (no Landlock net < 6.7), scopes absent (< 6.12, so the
signal / abstract-UDS vectors self-skip), and the x32 deny-mirror asserted
regardless. The guest is kept dependency-light: the suite is stdlib + pytest
only, so we install pytest via uv on PYTHONPATH and pass --noconftest to skip
tests/conftest.py's heavy imports (its fixtures are all in-module).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "copy repo into the VM" step tar'd the whole workspace, which by that point
holds the running VM's al2023.qcow2 (QEMU actively writing it) plus seed.iso /
serial.log / repo.tgz — tar aborts with "file changed as we read it". Use
git archive HEAD instead: a clean snapshot of tracked source, which is all the
escape suite needs and structurally cannot include the runtime artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
actions/checkout@v4 and upload-artifact@v4 run on the deprecated node20 runtime,
which emits a deprecation warning now that the runner defaults to node24. Bump
both to v5 (first major on node24) to clear it; our usage (plain checkout,
path-list upload) is unaffected by the majors' breaking changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ressions

Negative control for the sandbox-escape (kernel 6.1) workflow. Disables the
seccomp socket() network block so socket(AF_INET/AF_NETLINK/AF_VSOCK,…) succeeds,
breaking the "No network" guarantee. The escape suite must go RED (inet_sock /
netlink / vsock / net_ext / imds leak) — proving the CI fails when the sandbox
is broken, not just when it works.

REVERT THIS COMMIT before merging. Verified locally: run-confined builds and
tests/test_sandbox_escape.py fails on the network probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base image baked the run-confined command-sandbox launcher onto PATH
unconditionally (Dockerfile stage 1b + a COPY into /usr/local/bin). Not every
AutoProver container needs the sandbox, so pull it out of the base image and
provide it as a runtime-mounted, opt-in building block instead.

- scripts/Dockerfile: drop stage 1b and the run-confined COPY; leave a pointer
  to the overlay.
- scripts/Dockerfile.sandbox: builds run-confined in a throwaway Rust stage and
  publishes the binary into a mounted volume via a minimal bookworm-slim image.
- scripts/docker-compose.sandbox.yml: overlay with a one-shot run-confined-build
  service that populates a run_confined named volume, plus the wiring onto the
  autoprove service (read-only mount + RUN_CONFINED_BIN, gated behind the
  builder via service_completed_successfully). Consumers that don't add this
  overlay get a sandbox-free image.
- docs/command-sandbox.md: update the §9 note — the launcher is resolved via
  $RUN_CONFINED_BIN (overlay-mounted) rather than baked into the image.

The launcher provider already resolves $RUN_CONFINED_BIN ahead of PATH
(composer/sandbox/launcher.py), so no code change is needed. Verified
end-to-end: the publisher lands a 0755 ELF in the volume and
`run-confined --probe` reports `landlock FullyEnforced` from a read-only mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the command-sandbox hardening + the opt-in Docker overlay from
eric/sandbox onto the Crucible branch.

Crucible's run-confined was the original pre-hardening baseline (its main.rs
etc. were byte-identical to the sandbox PR-1 commit), so the security fixes
merge cleanly as "take theirs":

- run-confined: close the io_uring network bypass and the x32-ABI seccomp
  bypass (unconditional egress on kernels < 6.7, incl. the AL2023 6.1 target),
  Landlock scopes + TCP default-deny, grant only shared cargo bin/. Plus the
  recipes.py / command-sandbox.md / escape-suite updates that go with them.
- CI: the sandbox-escape-6.1.yml workflow + provision script.

Docker: the run-confined launcher is no longer baked into the base image.
Consumers opt in via the new scripts/docker-compose.sandbox.yml overlay
(builds scripts/Dockerfile.sandbox, mounts the binary at $RUN_CONFINED_BIN).
Crucible's containerized path — whose default provider is fail-closed — must
add that overlay.

Conflict resolution notes:
- rust/Cargo.toml: kept crucible's (workspace members autoprover-sdk /
  crucible-app / example-app; sandbox never changed this file from base).
- rust/Cargo.lock: dropped the incoming file — crucible gitignores it and the
  incoming lock covers only run-confined, not the app members.
- scripts/Dockerfile: applied the overlay change (dropped stage 1b + the
  run-confined COPY) while keeping crucible's crucible_kb.rag.json COPY.

Verified: cargo build -p run-confined --release succeeds and `--probe` reports
landlock FullyEnforced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base image no longer bundles run-confined (it moved to the
scripts/docker-compose.sandbox.yml overlay). Document the binary-resolution
order for the host demo (dev fallback still works unchanged) and tell
containerized Crucible runs to add the overlay, since the launcher provider is
fail-closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Crucible toolchain image + compose overlay so `console-crucible` and the
crucible test gates run entirely in-container, and make the gates container-aware.

Toolchain image (Option 1 / one blessed combo — docs/crucible-toolchain-versioning.md):
- scripts/Dockerfile.crucible layers on the lean base and bakes rustc 1.89.0 (the
  scenario's harness toolchain) + a newer CLI toolchain (anchor-cli needs rustc
  >= 1.91), Solana/agave platform-tools 3.1.10, anchor-cli 1.1.2, and the public
  crucible v0.2.0 checkout + CLI, plus the crucible_app + test dep groups.
- The toolchain installs into the runtime $HOME (/opt/autoprove/home) — the exact
  layout the RunCommand sandbox recipe grants (RUSTUP_HOME/CARGO_HOME +
  ~/.local/share/solana + ~/.cache/solana), so the confined build can exec it.
  A build-time sBPF build of the bundled scenario pre-installs platform-tools so
  the offline/non-root run-time build finds them present.
- scripts/docker-compose.crucible.yml swaps the autoprove service onto this image;
  it stacks on the sandbox overlay (Crucible is fail-closed, so run-confined is
  mounted from there).

Wiring:
- autoprove-entrypoint.sh: console-crucible/tui-crucible fail-fast if the toolchain
  or run-confined is missing (RAG DSN derives from PG env; no --rag-db needed).
- docker-compose.sandbox.yml: drop the profile gate on run-confined-build — a
  depends_on a profiled service is "undefined" to `compose build`/`config` unless
  that profile is also passed, which broke the single-profile crucible build.

Tests runnable in-container:
- conftest: pg_container targets an existing postgres via $COMPOSER_TEST_PG_URL
  (the compose service) instead of testcontainers — no docker-in-docker.
- test_crucible_e2e_gate / test_crucible_sandbox_gate: build a writable copy of the
  scenario (the in-image copy is read-only for the non-root user; also stops host
  runs polluting test_scenarios/), and the e2e provisions roles/DBs idempotently
  against the persistent compose postgres.
- docs/crucible-demo.md §8: the full containerized run + in-container e2e recipe.

Verified: base + crucible images build from scratch; the sandbox gate
(cargo-build-sbf under run-confined, offline) passes in-container on the clean
image and on the host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The package-data glob `templates/*.j2` only matched the top level, so the wheel
omitted composer/templates/solana/*.j2 and templates/rust/*.j2 (and the report's
prism-cvl.js). Any run whose CWD isn't the source tree loaded templates from the
installed wheel and hit `TemplateNotFound: 'solana/analysis_system.j2'` — which is
exactly the containerized `console-crucible` (CWD=/work). The source-tree runs
(pytest inserts the repo root on sys.path) masked it.

Recurse into the subdirs and include the JS asset. Verified: the built wheel now
contains solana/*.j2 (7), rust/*.j2, and prism-cvl.js; console-crucible in the
container gets through analysis + property extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The confined Rust build failed on a read-only shared RUSTUP_HOME:

    error: could not create temp file /…/.rustup/tmp/…: Permission denied (os error 13)

Even with a fully pre-installed toolchain, the rustup proxy (cargo/rustc/
cargo-build-sbf) writes scratch into $RUSTUP_HOME/tmp on every invocation, and the
recipe granted RUSTUP_HOME read-only — so the containerized harness build (whose
image bakes a shared, read-only toolchain) died on every setup attempt, and Crucible
gave up "did not pass compile/judge in 7 attempts". Host dev flows masked it: there
~/.rustup is writable.

Fix mirrors the existing private per-run CARGO_HOME (sandbox_rustup_home): point
RUSTUP_HOME at <workdir>/.sandbox_rustup, symlink `toolchains` back to the shared
home (still granted read-only, so toolchain bytes are shared not copied) and keep
tmp/downloads/update-hashes as this run's writable scratch. `settings.toml` is copied
so default/override resolution still works. No new shared-writable grant, so the
untrusted-build isolation the escape suite guards is unchanged.

Also exclude `.sandbox_rustup/` from RUST_FORBIDDEN_READ — its `toolchains` symlink
would otherwise make the source tools enumerate the whole toolchain into the prompt.

Verified: the confined sbf build (a rustup proxy) passes on the host under the new
per-run home.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s it)

The in-container e2e reached the harness setup and failed every attempt with, in
the offline confined build:

    error: could not download file from '…/channel-rust-stable.toml.sha256'
      to '…/.sandbox_rustup/tmp/…': dns error (offline sandbox)

Root cause: crucible-fuzz-cli pins the harness build to the `stable` toolchain — it
writes a `rust-toolchain.toml` with `channel = "stable"` into the fuzz crate and sets
`RUSTUP_TOOLCHAIN=stable` (crates/crucible-fuzz-cli/src/lib.rs). The image installed
1.89.0 (scenario) + a newer CLI toolchain but NOT `stable`, so the confined, offline
build tried to rustup-download it and failed. Host dev flows always have a `stable`
toolchain, which is why only the baked image hit this.

Fix: preinstall `stable` alongside the others so the offline build finds it present.
(The per-run RUSTUP_HOME from the previous commit remains correct defense-in-depth —
rustup needs writable scratch against the read-only baked toolchain — but installing
`stable` is what actually unblocks the harness build.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants